-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathComposeViewModel.swift
More file actions
1807 lines (1682 loc) · 79.9 KB
/
Copy pathComposeViewModel.swift
File metadata and controls
1807 lines (1682 loc) · 79.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import Foundation
import Observation
import CoreGraphics
import SwiftUI
import PhotosUI
@Observable
@MainActor
final class ComposeViewModel {
let keypair: Keypair
/// The account that will sign and publish the post. Defaults to `keypair`
/// but can be changed mid-compose without affecting the globally active account.
var signingKeypair: Keypair
/// Mode is mutable because loading a draft can switch a `.new` composer into
/// a `.reply` based on the draft's reconstructed `e`/`p` tags.
var mode: ComposeMode
// MARK: - Editable state
var content: String = ""
var galleryMode: Bool = false
var explicit: Bool = false
var powEnabled: Bool = PowPreferences.snapshot().noteEnabled
/// True when the user has toggled "Private" in a reply composer. Routes
/// publish through `PrivateReplyPublisher` (NIP-17 gift wrap) instead of
/// the public kind-1 pipeline. Only meaningful in `.reply` mode — the
/// view hides the toggle in `.new` / `.quote`.
var isPrivate: Bool = false
/// True when the parent of a reply is itself private — the toggle is then
/// pre-set to ON and the user can't toggle it off (turning the chain
/// public mid-thread would leak the rumor id into a public kind-1).
var isPrivateLocked: Bool = false
var attachments: [ComposeAttachment] = []
var mentions: [InsertedMention] = []
var hashtags: [String] = []
// MARK: - Poll state (NIP-88 / NIP-69)
var pollEnabled: Bool = false
var pollOptions: [String] = ["", ""]
var pollType: Nip88.PollType = .singlechoice
var isZapPoll: Bool = false
var zapPollMinSats: Int? = nil
var zapPollMaxSats: Int? = nil
var pollEndsAt: Int? = nil
// MARK: - Autocomplete state
var mentionQuery: String?
var mentionCandidates: [MentionCandidate] = []
/// True while the NIP-50 relay lookup is in flight after the local
/// search has returned (no follows match). The view surfaces a
/// "Searching…" row so the user can tell the popup is working on
/// something rather than assuming the autocomplete is broken — relay
/// queries take 1-3 s vs the instant follows search.
var isMentionSearchingRemote: Bool = false
var emojiQuery: String?
var emojiCandidates: [CustomEmoji] = []
// MARK: - Publish lifecycle
var isPublishing: Bool = false
var isMining: Bool = false
var miningAttempts: Int = 0
var uploadProgress: String?
var countdownSeconds: Int?
var countdownTotalSeconds: Int = 10
var countdownStartedAt: Date?
var lastError: String?
var publishedEventId: String?
// MARK: - Drafts & scheduling
/// Set when the composer is opened from an existing draft. Reused on
/// subsequent saves so the same `d` tag updates the same draft.
var currentDraftId: String?
/// When non-nil, publish goes to the scheduler relay with this `created_at`.
var scheduleAt: Date?
/// Set after a successful `saveDraft()` — UI uses this to dismiss.
var draftSaved: Bool = false
/// True once the user has explicitly chosen to discard via the Cancel dialog.
/// Suppresses the auto-save-on-disappear path.
var explicitlyDiscarded: Bool = false
var scheduleEnabled: Bool { scheduleAt != nil }
/// True when there is text/content the user might lose if the sheet is dismissed.
/// Counts uploaded attachments too — picking an image and swipe-dismissing should
/// still autosave the draft even if the user hasn't typed anything yet.
var hasUnsavedContent: Bool {
if !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true }
return attachments.contains { $0.url != nil }
}
// MARK: - Private
@ObservationIgnored private var blossomServers: [String] = [BlossomServerList.defaultServer]
@ObservationIgnored private var blossomLoaded = false
@ObservationIgnored private var countdownTask: Task<Void, Never>?
@ObservationIgnored private var publishContinuation: CheckedContinuation<Void, Never>?
@ObservationIgnored private var mineTask: Task<Void, Never>?
/// Debounce handle for social-preview cache warming. Cancelled and
/// recreated on each content change so a URL typed character by
/// character doesn't fan out a fetch for every partial fragment.
@ObservationIgnored private var socialPreviewPrefetchTask: Task<Void, Never>?
@ObservationIgnored private var autosaveTask: Task<Void, Never>?
private var powDifficulty: Int { PowPreferences.shared.noteDifficulty }
/// Track mention triggers via a sentinel index into the content string. When the
/// `@` signal is active this is the UTF-16 offset of the `@` character.
@ObservationIgnored private var mentionStartUtf16: Int?
@ObservationIgnored private var mentionEndUtf16: Int?
@ObservationIgnored private var emojiStartUtf16: Int?
@ObservationIgnored private var mentionRemoteTask: Task<Void, Never>?
// MARK: - Init
init(keypair: Keypair, mode: ComposeMode = .new, initialText: String = "") {
self.keypair = keypair
self.signingKeypair = keypair
self.mode = mode
// If we're composing a reply to a rumor the user already has marked
// private, force the privacy toggle on and lock it. This keeps the
// whole chain encrypted — a public reply mid-chain would leak the
// rumor id into a publicly indexed kind-1 event.
if case .reply(let parent, _) = mode,
PrivateInteractionStore.shared.contains(parent.id) {
isPrivate = true
isPrivateLocked = true
}
// Reply / quote drafts are keyed per-parent so closing a half-typed reply
// and reopening the same parent restores the body. The quote URI is still
// spliced at publish time, and reply context still lives in tags — only
// the editor body is restored.
loadLocalAutosave()
if !initialText.isEmpty { content = initialText }
}
// MARK: - Local autosave (instant restore on reopen)
/// Per-pubkey, per-mode UserDefaults bucket. Reply and quote drafts are keyed
/// by the parent / quoted event id so each conversation has its own slot.
private var autosaveKey: String {
// Local autosave is a "I closed the composer by accident, give
// me back my text" recovery mechanism — it belongs to the
// human at the device (the logged-in `keypair`), not to the
// temporary signing identity. Switching the per-compose signer
// doesn't move the draft. The NIP-37 published draft variant
// below DOES use `signingKeypair` because that draft represents
// a relay-side artifact attributable to whoever will sign the
// final post.
switch mode {
case .new:
return "compose_autosave_new_\(keypair.pubkey)"
case .reply(let parent, _):
return "compose_autosave_reply_\(keypair.pubkey)_\(parent.id)"
case .quote(let event):
return "compose_autosave_quote_\(keypair.pubkey)_\(event.id)"
}
}
func writeLocalAutosave() {
// Don't autosave when editing a saved draft — the draft is the source of truth
// and writes go through `saveDraft()`. Otherwise opening a draft would clobber
// the composer's autosave with the draft's content.
guard currentDraftId == nil else { return }
// Private replies never persist a local draft. The buffer disappears on
// sheet dismissal — sending a private reply later means retyping. The
// alternative (a UserDefaults bucket holding the intended-private body)
// would survive cleartext on disk, which violates the privacy intent.
if isPrivate { return }
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
let uploaded = attachments.filter { $0.url != nil }
guard !trimmed.isEmpty || !uploaded.isEmpty else {
UserDefaults.standard.removeObject(forKey: autosaveKey)
return
}
var payload: [String: Any] = [
"content": content,
"explicit": explicit,
"powEnabled": powEnabled
]
if let ts = scheduleAt?.timeIntervalSince1970 {
payload["scheduleAt"] = ts
}
let mentionDicts: [[String: String]] = mentions.map { ["displayName": $0.displayName, "pubkey": $0.pubkey] }
if !mentionDicts.isEmpty {
payload["mentions"] = mentionDicts
}
let attachmentDicts: [[String: Any]] = uploaded.map { a in
var d: [String: Any] = [
"url": a.url ?? "",
"mime": a.mime,
"dimW": a.dim.width,
"dimH": a.dim.height
]
if let h = a.sha256Hex { d["sha256"] = h }
if let s = a.durationSec { d["duration"] = s }
return d
}
if !attachmentDicts.isEmpty {
payload["attachments"] = attachmentDicts
}
UserDefaults.standard.set(payload, forKey: autosaveKey)
}
/// Debounced entry point for the per-keystroke autosave triggers. The
/// compose `TextEditor` binds through a custom `Binding`; running the
/// `UserDefaults` serialization synchronously inside the keystroke commit
/// transaction destabilises the editor and leaves a phantom caret on the
/// previously typed word. Coalescing the write off the keystroke avoids it.
func scheduleLocalAutosave() {
autosaveTask?.cancel()
autosaveTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
self?.writeLocalAutosave()
}
}
/// Cancel any pending debounced write and persist immediately. Called from
/// the view's `onDisappear` so a swipe-dismiss right after the last
/// keystroke still flushes the autosave bucket for the reopen path.
func flushLocalAutosave() {
autosaveTask?.cancel()
autosaveTask = nil
writeLocalAutosave()
}
func clearLocalAutosave() {
autosaveTask?.cancel()
autosaveTask = nil
UserDefaults.standard.removeObject(forKey: autosaveKey)
}
private func loadLocalAutosave() {
guard let payload = UserDefaults.standard.dictionary(forKey: autosaveKey) else { return }
let saved = payload["content"] as? String ?? ""
let restored: [ComposeAttachment] = (payload["attachments"] as? [[String: Any]] ?? []).compactMap { d in
guard let url = d["url"] as? String, !url.isEmpty else { return nil }
let mime = d["mime"] as? String ?? "image/jpeg"
let w = d["dimW"] as? Double ?? 0
let h = d["dimH"] as? Double ?? 0
return ComposeAttachment(
id: UUID(),
url: url,
mime: mime,
dim: CGSize(width: w, height: h),
durationSec: d["duration"] as? Int,
sha256Hex: d["sha256"] as? String,
localBytes: nil
)
}
let restoredMentions: [InsertedMention] = (payload["mentions"] as? [[String: String]] ?? []).compactMap { d in
guard let dn = d["displayName"], let pk = d["pubkey"] else { return nil }
return InsertedMention(displayName: dn, pubkey: pk)
}
guard !saved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !restored.isEmpty else { return }
content = saved
attachments = restored
mentions = restoredMentions
explicit = payload["explicit"] as? Bool ?? false
powEnabled = payload["powEnabled"] as? Bool ?? powEnabled
if let ts = payload["scheduleAt"] as? TimeInterval {
scheduleAt = Date(timeIntervalSince1970: ts)
}
}
// MARK: - Account selection
/// All saved accounts that can sign (excludes watch-only).
var availableSigningAccounts: [Keypair] {
NostrKey.accounts()
.filter { !NostrKey.isWatchOnly(pubkey: $0) }
.compactMap { NostrKey.loadAccount(pubkey: $0) }
}
/// Switch the signing identity for this compose session without affecting
/// the globally active account. Blossom server list is refreshed for the
/// new account; current editor text is preserved.
func switchSigningAccount(_ newKeypair: Keypair) {
signingKeypair = newKeypair
blossomServers = BlossomServerList.cached(for: newKeypair.pubkey)
Task { [pubkey = newKeypair.pubkey] in
let fresh = await BlossomServerList.refresh(for: pubkey)
await MainActor.run { self.blossomServers = fresh }
}
// Profiles for secondary accounts may not be in the in-memory
// cache yet — kick a fetch so the header avatar / display name
// resolve instead of falling back to person.fill + short-npub.
Task { [pubkey = newKeypair.pubkey] in
_ = await ProfileRepository.shared.ensure([pubkey])
}
}
// MARK: - Lifecycle
func start() async {
if !blossomLoaded {
blossomServers = BlossomServerList.cached(for: signingKeypair.pubkey)
blossomLoaded = true
// Refresh in the background; first composer open after install hits the network.
Task { [pubkey = signingKeypair.pubkey] in
let fresh = await BlossomServerList.refresh(for: pubkey)
await MainActor.run { self.blossomServers = fresh }
}
}
Task { await EmojiRepository.shared.refresh(for: keypair.pubkey) }
// Pre-warm follow profiles so @-mention search can match by name
// even for follows whose kind-0 hasn't been pulled in by the feed
// path yet. Without this, MentionSearch falls through to the npub
// fallback for unloaded follows and can't match user-typed names.
let follows = FollowsCache.shared.follows(for: keypair.pubkey)
if !follows.isEmpty {
Task { _ = await ProfileRepository.shared.ensure(follows) }
}
// Pre-warm profiles for every signable account so the composer
// avatar (and the account-picker menu) show the right name +
// picture from the moment the composer opens — instead of
// falling through to the npub placeholder for accounts whose
// kind-0 hasn't landed in this install yet.
let signers = availableSigningAccounts.map(\.pubkey)
if !signers.isEmpty {
Task { _ = await ProfileRepository.shared.ensure(signers) }
}
// Default mention popup state: empty until the user types `@`.
}
// MARK: - Toggles
func toggleGallery() {
guard mode.allowsGalleryToggle else { return }
galleryMode.toggle()
if galleryMode { pollEnabled = false }
}
func toggleNsfw() { explicit.toggle() }
func togglePow() { powEnabled.toggle() }
/// Toggle the "Private" reply state. No-op when locked (replying to a
/// rumor that's already private) — the chain stays encrypted end-to-end.
func togglePrivate() {
guard !isPrivateLocked else { return }
isPrivate.toggle()
}
// MARK: - Poll mutation
func togglePoll() {
guard mode.allowsPollToggle else { return }
pollEnabled.toggle()
if pollEnabled { galleryMode = false }
}
func updatePollOption(at index: Int, _ text: String) {
guard pollOptions.indices.contains(index) else { return }
pollOptions[index] = text
}
func addPollOption() {
guard pollOptions.count < 10 else { return }
pollOptions.append("")
}
func removePollOption(at index: Int) {
guard pollOptions.count > 2, pollOptions.indices.contains(index) else { return }
pollOptions.remove(at: index)
}
func togglePollType() {
pollType = (pollType == .singlechoice) ? .multiplechoice : .singlechoice
}
func toggleZapPoll() {
isZapPoll.toggle()
// Zap polls are always single-choice (Android forces this).
if isZapPoll { pollType = .singlechoice }
}
func setPollEndsAt(_ ts: Int?) {
pollEndsAt = ts
}
// MARK: - Content mutation
/// Called from the SwiftUI text-field binding. Re-derives mention/emoji/hashtag state.
func updateContent(_ new: String) {
// Auto-prefix bare bech32 (`nevent1...`, `note1...`, `nprofile1...`, `npub1...`) with `nostr:`.
let prefixed = autoPrefixBareBech32(new)
if prefixed != content {
content = prefixed
} else {
content = new
}
// Rehydrate `nostr:nprofile1...` / `nostr:npub1...` pastes into the
// `@displayName + mentions[]` form so they render as pills. Cheap
// guard avoids the regex on every keystroke; only runs when a paste
// actually introduces the URI form.
if content.contains("nostr:") {
rehydrateMentionsFromContent()
}
recomputeHashtags()
}
/// Insert text at the cursor (delegated by the view via a coordinator that knows the
/// caret). For simplicity v1 appends at end if cursor unknown.
func append(_ text: String) {
content += text
recomputeHashtags()
}
// MARK: - Mentions
/// Caller (the view) reports the substring after `@` and the offset of the `@` itself.
/// Pass `nil` to dismiss the popup.
func updateMentionTrigger(query: String?, atOffsetUtf16: Int?, endUtf16: Int? = nil) {
mentionStartUtf16 = atOffsetUtf16
mentionEndUtf16 = endUtf16
mentionQuery = query
mentionRemoteTask?.cancel()
guard let query else {
mentionCandidates = []
isMentionSearchingRemote = false
return
}
mentionCandidates = MentionSearch.search(query: query, currentUserPubkey: keypair.pubkey)
// Only fire the relay fallback when the query is long enough to
// disambiguate and not yet served locally. `searchRemote` itself
// gates on `.count >= 2`, but checking here too avoids spinning
// up the loading spinner for `@s` only to clear it instantly.
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= 2 else {
isMentionSearchingRemote = false
return
}
// Surface a "Searching…" row so the user can tell the popup is
// working on something — relay queries take 1-3 s vs the instant
// follows search and look broken without an affordance.
isMentionSearchingRemote = true
// The local pass is follows-only and only sees cached kind-0s, so an
// account the author doesn't follow yet (e.g. a fresh handle typed
// from memory) never appears. Fall back to a NIP-50 relay lookup,
// debounced so we don't fire a query per keystroke and guarded
// against staleness so a slow reply can't replace a newer query's
// results. 200 ms gives a typical typist time to add one more
// letter without firing two relay queries.
let pubkey = keypair.pubkey
mentionRemoteTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(200))
guard !Task.isCancelled, let self, self.mentionQuery == query else { return }
let existing = Set(self.mentionCandidates.map(\.pubkey))
let remote = await MentionSearch.searchRemote(
query: query,
currentUserPubkey: pubkey,
excluding: existing
)
guard !Task.isCancelled, self.mentionQuery == query else { return }
self.isMentionSearchingRemote = false
guard !remote.isEmpty else { return }
let known = Set(self.mentionCandidates.map(\.pubkey))
self.mentionCandidates.append(contentsOf: remote.filter { !known.contains($0.pubkey) })
}
}
func selectMention(_ candidate: MentionCandidate) {
guard let startOffset = mentionStartUtf16 else { return }
let displayName = sanitizeDisplayName(candidate.name)
let view = content.utf16
guard startOffset >= 0, startOffset <= view.count else { return }
let startIdx = view.index(view.startIndex, offsetBy: startOffset)
// Replace from `@` to end of current word (we use end-of-string as the cursor proxy
// when the view doesn't tell us otherwise — close enough for v1).
let replacement = "@\(displayName) "
let prefix = String(view[..<startIdx])!
let _ = replacement
let _ = prefix
// Actual replacement: replace from `startIdx` to end of the buffer back to where
// the user's cursor is. We approximate by replacing up to the next whitespace
// or end of string.
let s = content
guard let stringStart = s.utf16.index(s.utf16.startIndex, offsetBy: startOffset, limitedBy: s.utf16.endIndex),
let stringStartIdx = String.Index(stringStart, within: s) else { return }
// Use the stored cursor position as the replacement end when available
// (handles multi-word queries with spaces). Fall back to forward-scanning
// for callers that don't supply endUtf16.
var end: String.Index
if let endOffset = mentionEndUtf16,
let endUtf16Idx = s.utf16.index(s.utf16.startIndex, offsetBy: endOffset, limitedBy: s.utf16.endIndex),
let endIdx = String.Index(endUtf16Idx, within: s),
endIdx >= stringStartIdx {
end = endIdx
} else {
end = stringStartIdx
while end < s.endIndex, !s[end].isMentionTokenBreak { end = s.index(after: end) }
}
var newContent = s
newContent.replaceSubrange(stringStartIdx..<end, with: "@\(displayName) ")
content = newContent
mentions.append(InsertedMention(displayName: displayName, pubkey: candidate.pubkey))
mentionRemoteTask?.cancel()
mentionQuery = nil
mentionCandidates = []
isMentionSearchingRemote = false
mentionStartUtf16 = nil
mentionEndUtf16 = nil
recomputeHashtags()
}
// MARK: - Emoji
func updateEmojiTrigger(query: String?, atOffsetUtf16: Int?) {
emojiStartUtf16 = atOffsetUtf16
emojiQuery = query
if let query {
emojiCandidates = EmojiRepository.shared.search(query: query)
} else {
emojiCandidates = []
}
}
func selectEmoji(_ emoji: CustomEmoji) {
guard let startOffset = emojiStartUtf16 else { return }
content = EmojiShortcode.insert(emoji.shortcode, into: content, atUtf16: startOffset)
emojiQuery = nil
emojiCandidates = []
emojiStartUtf16 = nil
recomputeHashtags()
}
// MARK: - Media
/// Pick from `PhotosPickerItem` inputs, decode, compress, and upload to Blossom.
/// Updates `attachments` and `uploadProgress` as work proceeds.
func addMedia(items: [PhotosPickerItem]) async {
guard !items.isEmpty else { return }
uploadProgress = items.count > 1 ? "Loading \(items.count) items…" : "Loading…"
defer { if uploadProgress != nil { uploadProgress = nil } }
let pickResults = await MediaPicker.loadAll(items)
await uploadPickedMedia(pickResults)
}
/// Pick from `NSItemProvider` inputs delivered by `PHPickerViewController`.
/// Same end-to-end pipeline as `addMedia(items:)`; the only difference is
/// the loader path. Used by the UIKit `PhotosPickerPresenter` bridge that
/// avoids SwiftUI's sheet-cascade dismissal bug on compose.
func addMediaProviders(_ providers: [NSItemProvider]) async {
guard !providers.isEmpty else { return }
uploadProgress = providers.count > 1 ? "Loading \(providers.count) items…" : "Loading…"
defer { if uploadProgress != nil { uploadProgress = nil } }
let pickResults = await MediaPicker.loadAll(providers: providers)
await uploadPickedMedia(pickResults)
}
private func uploadPickedMedia(_ pickResults: [PickedMedia]) async {
guard !pickResults.isEmpty else { return }
let total = pickResults.count
var uploaded = 0
for picked in pickResults {
let pendingId = UUID()
let pendingMime = picked.mime
let pendingDim = picked.dim
var pendingDuration = picked.durationSec
// For videos `picked.data` is a poster JPEG, not the full clip.
let thumbBytes: Data? = picked.isVideo ? (picked.data.isEmpty ? nil : picked.data) : picked.data
let pending = ComposeAttachment(
id: pendingId,
url: nil,
mime: pendingMime,
dim: pendingDim,
durationSec: pendingDuration,
sha256Hex: nil,
localBytes: thumbBytes
)
attachments.append(pending)
do {
let prepared: (Data, String, CGSize)
if picked.isVideo {
guard let sourceURL = picked.sourceURL else {
attachments.removeAll { $0.id == pendingId }
lastError = "Couldn't read picked video."
continue
}
uploadProgress = total > 1
? "Compressing video \(uploaded + 1)/\(total)…"
: "Compressing video…"
do {
let r = try await MediaCompressor.compressVideo(sourceURL: sourceURL)
prepared = (r.data, r.mime, r.dim != .zero ? r.dim : pendingDim)
if let d = r.durationSec { pendingDuration = d }
} catch {
attachments.removeAll { $0.id == pendingId }
lastError = "Video compression failed: \(error)"
continue
}
uploadProgress = total > 1 ? "Uploading \(uploaded + 1)/\(total)…" : "Uploading…"
} else {
uploadProgress = total > 1 ? "Uploading \(uploaded + 1)/\(total)…" : "Uploading…"
let r = MediaCompressor.compressImage(data: picked.data, mime: pendingMime)
prepared = (r.data, r.mime, r.dim)
}
let result = try await BlossomClient.upload(
bytes: prepared.0,
mime: prepared.1,
servers: blossomServers,
keypair: signingKeypair
)
if let idx = attachments.firstIndex(where: { $0.id == pendingId }) {
attachments[idx] = ComposeAttachment(
id: pendingId,
url: result.url,
mime: prepared.1,
dim: prepared.2,
durationSec: pendingDuration,
sha256Hex: result.sha256Hex,
localBytes: nil
)
}
uploaded += 1
} catch {
attachments.removeAll { $0.id == pendingId }
lastError = "Upload failed: \(error)"
}
}
uploadProgress = nil
}
func removeMedia(at offsets: IndexSet) {
attachments.remove(atOffsets: offsets)
}
func removeMedia(id: UUID) {
attachments.removeAll { $0.id == id }
}
/// Handle images from a SwiftUI `.onPasteCommand([UTType.image])` callback.
/// Each provider is loaded to bytes, compressed, and uploaded to Blossom — same
/// pipeline as the photo picker. In non-gallery mode the resulting URL is also
/// appended to the post body so it shows up in the live preview alongside the
/// attachment thumbnail.
func addPastedImages(_ providers: [NSItemProvider]) async {
guard !providers.isEmpty else { return }
uploadProgress = providers.count > 1 ? "Loading \(providers.count) images…" : "Loading…"
defer { if uploadProgress != nil { uploadProgress = nil } }
var loaded: [(data: Data, mime: String)] = []
for provider in providers {
if let result = await loadPastedImageData(from: provider) {
loaded.append(result)
}
}
guard !loaded.isEmpty else { return }
let total = loaded.count
for (i, item) in loaded.enumerated() {
await uploadImageBytes(data: item.data, mime: item.mime, progressIndex: i, total: total)
}
uploadProgress = nil
}
/// Preference order matters: Safari's "Copy Image" on an animated GIF
/// publishes BOTH `com.compuserve.gif` and `public.png` representations,
/// and we have to read the GIF first or the PNG (frame zero) wins and
/// the animation is gone before bytes ever reach the compressor.
/// Animated formats first, then static.
private static let pasteImageTypes: [(typeId: String, mime: String)] = [
("com.compuserve.gif", "image/gif"),
("org.webmproject.webp", "image/webp"),
("public.png", "image/png"),
("public.jpeg", "image/jpeg"),
("public.heic", "image/heic")
]
private func loadPastedImageData(from provider: NSItemProvider) async -> (data: Data, mime: String)? {
// Source URL preserves animation when the inline bytes do not.
// Most browsers rasterize images on "Copy Image" — Chromium for
// example only writes `public.png` (frame zero of the source GIF)
// even when the source was animated. Fetching the original URL
// sidesteps that and gets the bytes the server actually serves.
let sourceUrl = await pasteboardSourceImageUrl(from: provider)
var inline: (data: Data, mime: String)?
for entry in Self.pasteImageTypes where provider.hasItemConformingToTypeIdentifier(entry.typeId) {
if let data = await loadDataRepresentation(from: provider, typeIdentifier: entry.typeId) {
inline = (data, entry.mime)
break
}
}
// Inline bytes already animated → no need to hit the network.
if let inline, MediaCompressor.isAnimated(inline.data) {
return inline
}
// Try the source URL when the inline bytes are static (or absent)
// and the URL looks like it might be image-flavoured. We trust the
// fetched bytes when they're animated; otherwise stick with whatever
// the clipboard provided so we don't pay a network round-trip for a
// worse result.
if let sourceUrl, let fetched = await fetchUrlImageBytes(sourceUrl) {
if MediaCompressor.isAnimated(fetched.data) {
return fetched
}
if inline == nil {
return fetched
}
}
if let inline { return inline }
// Fallback for type-id-less providers (rare): re-encode anything decodable as JPEG.
if let data = await loadDataRepresentation(from: provider, typeIdentifier: "public.image"),
let img = UIImage(data: data),
let jpeg = img.jpegData(compressionQuality: 0.92) {
return (jpeg, "image/jpeg")
}
return nil
}
/// UTIs that browsers / share extensions use to carry the source URL of
/// a copied image. Checked in order; first hit wins.
private static let pasteSourceUrlTypeIds: [String] = [
"org.chromium.source-url",
"public.url",
"public.utf8-plain-text"
]
private func pasteboardSourceImageUrl(from provider: NSItemProvider) async -> URL? {
for typeId in Self.pasteSourceUrlTypeIds where provider.hasItemConformingToTypeIdentifier(typeId) {
guard let data = await loadDataRepresentation(from: provider, typeIdentifier: typeId) else { continue }
guard let string = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty else { continue }
guard let url = URL(string: string), let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http" else { continue }
return url
}
return nil
}
private func fetchUrlImageBytes(_ url: URL) async -> (data: Data, mime: String)? {
var req = URLRequest(url: url)
req.setValue("image/*", forHTTPHeaderField: "Accept")
req.timeoutInterval = 10
do {
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { return nil }
// Trust the server's Content-Type only when it's an image MIME;
// otherwise sniff the bytes so a `text/html` redirect page doesn't
// get uploaded as an image.
let serverMime = (http.value(forHTTPHeaderField: "Content-Type") ?? "")
.split(separator: ";").first.map { String($0).trimmingCharacters(in: .whitespaces) } ?? ""
let mime: String
if serverMime.hasPrefix("image/") {
mime = serverMime
} else if let sniffed = sniffImageMime(data) {
mime = sniffed
} else {
return nil
}
return (data, mime)
} catch {
return nil
}
}
/// Magic-byte sniff for the formats our compressor handles. Used when the
/// server doesn't send a useful `Content-Type` header (e.g. CDNs that
/// return `application/octet-stream`).
private func sniffImageMime(_ data: Data) -> String? {
guard data.count >= 12 else { return nil }
let p = data.prefix(12)
if p.starts(with: [0x47, 0x49, 0x46]) { return "image/gif" }
if p.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
if p.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
if p.starts(with: [0x52, 0x49, 0x46, 0x46]) && p.dropFirst(8).starts(with: [0x57, 0x45, 0x42, 0x50]) {
return "image/webp"
}
return nil
}
private func loadDataRepresentation(from provider: NSItemProvider, typeIdentifier: String) async -> Data? {
await withCheckedContinuation { cont in
provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in
cont.resume(returning: data)
}
}
}
private func uploadImageBytes(data: Data, mime: String, progressIndex: Int, total: Int) async {
let pendingId = UUID()
let compressed = MediaCompressor.compressImage(data: data, mime: mime)
let pending = ComposeAttachment(
id: pendingId,
url: nil,
mime: compressed.mime,
dim: compressed.dim,
durationSec: nil,
sha256Hex: nil,
localBytes: data
)
attachments.append(pending)
uploadProgress = total > 1 ? "Uploading \(progressIndex + 1)/\(total)…" : "Uploading…"
do {
let result = try await BlossomClient.upload(
bytes: compressed.data,
mime: compressed.mime,
servers: blossomServers,
keypair: signingKeypair
)
if let idx = attachments.firstIndex(where: { $0.id == pendingId }) {
attachments[idx] = ComposeAttachment(
id: pendingId,
url: result.url,
mime: compressed.mime,
dim: compressed.dim,
durationSec: nil,
sha256Hex: result.sha256Hex,
localBytes: nil
)
}
} catch {
attachments.removeAll { $0.id == pendingId }
lastError = "Upload failed: \(error)"
}
}
/// Re-host a GIF picked from Giphy on the user's Blossom servers, then
/// append the resulting URL to the post body. Falls back to the original
/// Giphy URL if the rehost fails so the user always gets a working link.
func attachGifFromGiphy(_ giphyURL: String) async {
uploadProgress = "Uploading GIF…"
defer { uploadProgress = nil }
let outcome = await GifBlossomUploader.rehost(
giphyURL: giphyURL,
keypair: signingKeypair,
servers: blossomServers
)
if !content.isEmpty, !content.hasSuffix("\n") { content += "\n" }
content += outcome.url
content += "\n"
if !outcome.didRehost {
lastError = "Couldn't re-host GIF on your Blossom server — using the Giphy link instead."
}
}
// MARK: - Publish
/// Begin the 10-second undo countdown. Caller must keep the view alive — the
/// publish fires after the timer elapses unless `cancelPublish()` is called.
/// When `scheduleAt` is set, the countdown is skipped (no rush — the
/// scheduler relay holds the post until the chosen time).
func publish() {
guard countdownSeconds == nil, !isPublishing else { return }
if let validation = validate() {
lastError = validation
return
}
lastError = nil
if scheduleEnabled {
// Flip `isPublishing` synchronously so the button shows the
// spinner the moment the user taps. The pipeline's own
// `isPublishing = true` becomes a no-op; the `defer` still
// resets it on completion.
isPublishing = true
Task { await runPublishPipeline() }
return
}
// Resolve the user's undo-timer preference. Replies opt out by
// default — the default user wants confirmation on top-level posts
// but expects replies to send immediately like a chat.
let settings = AppSettings.shared
let isReply: Bool = { if case .reply = mode { return true } else { return false } }()
let useTimer = settings.postUndoTimerEnabled && (!isReply || settings.postUndoTimerForReplies)
guard useTimer, settings.postUndoTimerSeconds > 0 else {
isPublishing = true
Task { await runPublishPipeline() }
return
}
let totalSeconds = settings.postUndoTimerSeconds
// Show the countdown UI synchronously — without this the button
// stays on "Publish" until the Task scheduled below first runs,
// which on a busy main actor reads as a 1–2 s no-op.
countdownSeconds = totalSeconds
countdownTotalSeconds = totalSeconds
countdownStartedAt = Date()
countdownTask = Task { @MainActor [weak self] in
guard let self else { return }
for n in stride(from: totalSeconds - 1, through: 1, by: -1) {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
self.countdownSeconds = n
}
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
self.countdownSeconds = nil
self.countdownStartedAt = nil
await self.runPublishPipeline()
}
}
func publishNow() {
countdownTask?.cancel()
countdownTask = nil
countdownSeconds = nil
countdownStartedAt = nil
Task { await runPublishPipeline() }
}
func cancelPublish() {
countdownTask?.cancel()
countdownTask = nil
countdownSeconds = nil
countdownStartedAt = nil
mineTask?.cancel()
mineTask = nil
isPublishing = false
isMining = false
miningAttempts = 0
}
// MARK: - Drafts
/// Hydrate the composer from a previously saved draft. Reply context is
/// reconstructed from the draft's `e` and `p` tags — when the parent event
/// isn't in cache we synthesize a stub `NostrEvent` with id+pubkey only,
/// matching the Android client's behavior.
func loadDraft(_ draft: Nip37.Draft) {
currentDraftId = draft.dTag
// Restore the composer mode from the draft's inner kind. Kind 20
// (NIP-68 picture) and 21 / 22 (NIP-71 short-form video) round-trip
// as gallery posts; kind 1 stays in text mode. Without this, a
// gallery draft would silently reopen in text mode and republish
// as a kind-1 note with the URLs spliced into the body.
let isGalleryKind = (draft.innerKind == Nip68.kindPicture
|| draft.innerKind == Nip71.kindVideoHorizontal
|| draft.innerKind == Nip71.kindVideoVertical)
if isGalleryKind && mode.allowsGalleryToggle {
galleryMode = true
}
let imetaAttachments = Self.parseImetaAttachments(tags: draft.tags)
if !imetaAttachments.isEmpty {
// Imeta tags carry full attachment metadata (mime, dim, hash), so the
// round-trip is exact; the body stays as the user typed it.
content = draft.content
attachments = imetaAttachments
} else {
// Legacy drafts (saved before the imeta round-trip landed, or by other
// clients) put the URLs at the end of the body — peel them back off.
let (body, restoredAttachments) = Self.splitDraftBody(draft.content)
content = body
attachments = restoredAttachments
}
recomputeHashtags()
// Mentions persist in the body as `nostr:nprofile1...` URIs. Convert
// each back to `@displayName` and seed `mentions` so the rich editor
// can render them as pills — without this, drafts come back as raw
// bech32 strings even though the on-disk format is identical to what
// `materializeMentions` produces for a fresh post. `materializeMentions`
// at publish time will turn them back into URIs, so this is a pure
// display rehydrate.
rehydrateMentionsFromContent()
// Reconstruct reply context from draft tags (Android: Navigation.kt:989).
let replyTag = draft.tags.first(where: {
$0.count >= 4 && $0[0] == "e" && $0[3] == "reply"
})
let rootTag = draft.tags.first(where: {
$0.count >= 4 && $0[0] == "e" && $0[3] == "root"
})
let parentTag = replyTag ?? rootTag ?? draft.tags.first(where: {
$0.count >= 2 && $0[0] == "e"
})
if let parentTag, parentTag.count >= 2 {
let parentId = parentTag[1]
let parentAuthor = draft.tags.first(where: { $0.count >= 2 && $0[0] == "p" })?[1] ?? ""
let parentStub = NostrEvent(
id: parentId, pubkey: parentAuthor, kind: 1,
createdAt: 0, tags: [], content: "", sig: ""
)
let rootStub: NostrEvent? = {
guard let rootTag, rootTag.count >= 2, rootTag[1] != parentId else { return nil }
return NostrEvent(
id: rootTag[1], pubkey: parentAuthor, kind: 1,
createdAt: 0, tags: [], content: "", sig: ""
)
}()
mode = .reply(parent: parentStub, root: rootStub)
} else if let quoteTag = draft.tags.first(where: { $0.count >= 2 && $0[0] == "q" }) {
let quotedId = quoteTag[1]
let quotedAuthor = draft.tags.first(where: { $0.count >= 2 && $0[0] == "p" })?[1] ?? ""
let stub = NostrEvent(
id: quotedId, pubkey: quotedAuthor, kind: 1,
createdAt: 0, tags: [], content: "", sig: ""
)
mode = .quote(stub)
}
}