eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import SwiftUI
3
4// The per-instance sidebar model. Fetches the thread list, unwraps each thread's
5// key *natively* (ECIES, via the user private key in the Keychain), and decrypts
6// just enough — group names + last-message previews — to render the sidebar. The
7// message feed itself is rendered by the webview, handed only the active thread's
8// already-unwrapped key (ThreadStore never gives the webview the private key).
9@MainActor
10public final class ThreadStore: ObservableObject {
11 @Published public var threads: [ChatThread] = []
12 @Published public var instanceName: String = ""
13 @Published public var loading = false
14 @Published public var loadError: String?
15
16 @Published public var roster: [RosterEntry] = [] // joined members (for the new-chat picker)
17
18 // Per-instance feature gates (admin-set, default on) — the pickers hide
19 // their bot/app entry points when off; the server enforces regardless.
20 @Published public private(set) var botsEnabled = true
21 @Published public private(set) var appsEnabled = true
22
23 public let instance: SavedInstance
24 public private(set) var myUserId: String
25 private var instanceKeyB64: String? // for member avatars (optional)
26
27 // Space-wide app installs (docs/space-apps.md): folded from the hidden
28 // space thread once the instance key is unwrapped. Owned here so its
29 // lifecycle rides this store's; the platform socket handlers route the
30 // space thread's frames to it (reloadSpaceApps / spaceApps.handleLive).
31 public private(set) lazy var spaceApps = SpaceAppsStore(instance: instance)
32
33 /// This space's app registry (docs/native-apps.md Layer 1) — the one
34 /// source for app titles, glyphs, places, presentation, and pickers.
35 public var registry: AppRegistry { AppRegistry.shared(origin: instance.origin) }
36 private var connectedFetchedAt = Date.distantPast
37 private var catalogHostKicked = false
38
39 // The app's own preview hook, answered through the app host (the voice
40 // surfaces' override): cached per (thread, last_at) so refresh churn
41 // never re-queries an unchanged row.
42 private var previewCache: [String: (at: Int64, text: String)] = [:]
43
44 private func fillPreviews(_ queries: [PreviewQuery]) {
45 // Cached answers apply immediately; only genuinely new last-events
46 // ride to the host.
47 var pending: [PreviewQuery] = []
48 for q in queries {
49 if let hit = previewCache[q.threadID], hit.at == q.at {
50 applyPreview(q.threadID, at: q.at, text: hit.text)
51 } else {
52 pending.append(q)
53 }
54 }
55 guard !pending.isEmpty else { return }
56 Task { @MainActor [weak self] in
57 guard let self,
58 let t = self.threads.first(where: { !$0.threadKeyB64.isEmpty }) else { return }
59 let host = CommandHostCache.shared.host(for: self.instance, thread: t,
60 myUserId: self.myUserId,
61 instanceKeyB64: self.instanceKeyB64)
62 for q in pending {
63 guard let text = await host.previewEvent(q.eventJSON, you: q.you) else { continue }
64 self.previewCache[q.threadID] = (q.at, text)
65 self.applyPreview(q.threadID, at: q.at, text: text)
66 }
67 }
68 }
69
70 private func applyPreview(_ threadID: String, at: Int64, text: String) {
71 guard let i = threads.firstIndex(where: { $0.id == threadID }),
72 threads[i].lastAt == at, threads[i].preview != text else { return }
73 threads[i].preview = text
74 }
75
76 /// An apps_changed frame reached the sidebar socket (someone deployed or
77 /// changed a custom/member app): invalidate the catalogue and re-pull
78 /// through a command host — minting one if no page is alive to answer.
79 public func refreshAppCatalog() {
80 registry.invalidateLiveReport()
81 guard let t = threads.first(where: { !$0.threadKeyB64.isEmpty }) else { return }
82 let host = CommandHostCache.shared.host(for: instance, thread: t,
83 myUserId: myUserId,
84 instanceKeyB64: instanceKeyB64)
85 // Reload, not just re-pull: a custom app is an ES module the page
86 // imported at boot, so a live page can never report a new one. The
87 // fresh boot's ready-pull reports the whole set (the latch above was
88 // just dropped).
89 host.reloadForCatalog()
90 }
91 // Sealed-branding latch: the decrypted name, resolved at most once per
92 // store lifetime (see refresh — the resolver is a 600k-iteration PBKDF2).
93 private var sealedInstanceName: String?
94 private var sealedNameAttempted = false
95 private var tierKeysB64: [String: String] = [:] // level id -> our unwrapped key
96 private var tiers: [TierRow] = [] // /api/tiers rows, personalised
97 private var rosterLabels: [String: String] = [:]
98
99 // Per-viewer names (#577): what *I* call people, opened from the sealed
100 // blob only this member's devices can read. displayLabels is the resolved
101 // view (my name for them → their published name) display surfaces read.
102 @Published public private(set) var nicknames: [String: NicknameEntry] = [:]
103 private var displayLabels: [String: String] = [:]
104
105 /// A copy of the resolved sender-name map (uid → the name THIS member
106 /// shows for them) for surfaces that resolve senders off the main actor —
107 /// the Mac's notification banners, mirroring the iOS extension's parked
108 /// `entry.names` lookup.
109 public var senderLabels: [String: String] { displayLabels }
110 private var nicknamesVersion: Int64 = 0
111 // Ops this device has made that the server copy may not have yet
112 // (uid → entry, nil = cleared): saves re-read the server blob and replay
113 // these on top — the web client's journal (shell.js), mirrored.
114 private var nicknameOps: [String: NicknameEntry?] = [:]
115
116 // Muted people (#578): who I've muted, opened from the sealed prefs blob
117 // (/api/prefs) — the same seal-to-my-own-key + CAS shape as nicknames.
118 // Their messages fold to a quiet stub and never notify; nothing changes
119 // for them, and the server never learns who's muted.
120 @Published public private(set) var mutedPeople: Set<String> = []
121 private var prefsVersion: Int64 = 0
122
123 // Blocked people (#578): who I've blocked. Unlike mutes the record lives
124 // SERVER-side — it's what enforces no new contact (DMs, group adds,
125 // pushes) — so it's a plain uid list read on refresh, not a sealed pref.
126 @Published public private(set) var blockedPeople: Set<String> = []
127 // uid → true (mute) / false (unmute), pending sync — the nicknames journal
128 // shape, replayed over a fresh server read on every save.
129 private var mutedOps: [String: Bool] = [:]
130
131 // The fetch service this instance prefers for unfurls (docs/fetch.md),
132 // latched from FeatureGates on refresh. Lives here because `api` below is
133 // computed fresh per access — state set on one copy would evaporate.
134 private var fetchServiceURL = ""
135
136 private var api: LChatAPI? {
137 guard let base = instance.baseURL else { return nil }
138 var a = LChatAPI(baseURL: base, token: instance.deviceToken)
139 a.fetchURL = fetchServiceURL
140 return a
141 }
142
143 public init(instance: SavedInstance) {
144 self.instance = instance
145 self.myUserId = instance.myUserId
146 }
147
148 /// The unwrapped instance key (base64url), handed to the webview so it can show
149 /// member profile photos. nil until loaded / if we haven't been granted it.
150 public func instanceKey() -> String? { instanceKeyB64 }
151
152 /// Whether this member holds the admin role — my own roster entry carries it.
153 /// Gates admin-only UI (the Admin app's picker rows) as a nicety only: the
154 /// server re-checks the role on every admin API call. False until the roster
155 /// loads, so the rows simply appear with it.
156 public var isAdmin: Bool {
157 roster.first { $0.user_id == myUserId }?.role == "admin"
158 }
159
160 /// The photo source for a roster member — their profile photo (instance-key
161 /// encrypted), or nil if they have none / we lack the key or address. For the
162 /// new-chat picker. Mirrors avatarFor in web/static/chat.js.
163 public func avatarSource(for entry: RosterEntry) -> AvatarSource? {
164 memberAvatar(entry.avatar)
165 }
166
167 // A member's profile photo (instance key), from their blob id. Shared by the
168 // picker and the DM/self thread rows built in refresh().
169 private func memberAvatar(_ blobID: String?) -> AvatarSource? {
170 guard let blobID, !blobID.isEmpty, let key = instanceKeyB64,
171 let base = instance.baseURL else { return nil }
172 return AvatarSource(blobID: blobID, keyB64: key, baseURL: base, token: instance.deviceToken)
173 }
174
175 /// Local-first render: publish the last session's folded rows (parked in
176 /// the Keychain by refresh) so entering a space you've been to shows its
177 /// chats immediately — the network refresh then reconciles in the
178 /// background, however long it takes. A no-op once anything is loaded, so
179 /// the loading scene is a truly-first-visit state.
180 public func hydrate() async {
181 guard threads.isEmpty else { return }
182 // Touch the registry before the first rows publish: its init pushes
183 // the seed's decoration (app symbols/marks) into AppIdentity, so the
184 // first frame's rows wear their chrome instead of flashing emoji.
185 _ = registry
186 let origin = instance.origin
187 let span = Perf.begin("threads.hydrate", slowMs: 500)
188 defer { span.end() }
189 guard let snap = await Task.detached(priority: .userInitiated, operation: {
190 ThreadListSnapshot.load(origin: origin)
191 }).value else { return }
192 guard threads.isEmpty else { return } // a refresh raced us — it wins
193 threads = snap.rows
194 if instanceName.isEmpty { instanceName = snap.name }
195 }
196
197 // Overlap-proofing: refresh() is called from every socket frame (via the
198 // list view's debounce, which a >1s pass still outlives), inboxPing,
199 // foregrounding, and pull-to-refresh — uncoalesced, those overlapped into
200 // duplicate concurrent /api/threads fetches whose stacked publishes were
201 // the 2026-07-20 trace's frame hitches (see CoalescedRunner).
202 private let refreshGate = CoalescedRunner()
203
204 public func refresh() async {
205 await refreshGate.run { await self.refreshPass() }
206 }
207
208 private func refreshPass() async {
209 guard let api else { loadError = "This instance's address is invalid."; return }
210 // The published spinner only matters before the first rows exist —
211 // steady-state refreshes (every message anywhere lands here) must not
212 // toggle a @Published var twice per pass: each objectWillChange
213 // re-renders the whole chat list AND re-evaluates the pushed chat
214 // screen (#465).
215 let showSpinner = threads.isEmpty
216 if showSpinner { loading = true }
217 // Refires on every socket frame, so the budget is generous — only a
218 // genuinely wedged refresh (slow roster + key unwraps) should record.
219 let span = Perf.begin("threads.refresh", slowMs: 2500)
220 defer {
221 if showSpinner { loading = false }
222 span.end("\(threads.count) threads")
223 }
224
225 await api.ensurePubkey(instance.userPub)
226
227 // Independent of the fetches below — runs concurrently, off the hot
228 // path (refresh refires on every socket frame).
229 async let nickFetchAsync = api.nicknames()
230 async let prefsFetchAsync = api.prefs()
231 async let gatesAsync = api.featureGates()
232 async let blocksAsync = api.blocks()
233
234 let people = await api.roster()
235 if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) {
236 myUserId = me.user_id
237 }
238 // Full status, not just our wrapped copy: `have` is what tells a
239 // keyless member the key EXISTS and holders can be nudged (#1376).
240 let instanceKeyState = await api.instanceKeyStatus()
241 let wrappedInstanceKey = instanceKeyState?.wrapped
242 // Sealed branding: groupName() deliberately degrades to "" on a sealed
243 // instance — we're a member, so open the blob via the instance-key
244 // copy of B (SealedBranding.swift). LATCHED to one resolution per
245 // store lifetime: refresh() refires on every socket frame, and the
246 // resolver costs an authed GET plus a 600k-iteration PBKDF2 — the
247 // fetched "" must never clobber a name we already resolved.
248 let fetchedName = await api.groupName()
249 if !fetchedName.isEmpty {
250 if instanceName != fetchedName { instanceName = fetchedName }
251 } else if let sealed = sealedInstanceName {
252 if instanceName != sealed { instanceName = sealed }
253 } else if !sealedNameAttempted {
254 sealedNameAttempted = true
255 if let opened = await SealedBrandingResolver.open(api: api, userPrivPkcs8B64: instance.userPriv),
256 let name = opened.branding.name, !name.isEmpty {
257 sealedInstanceName = name
258 instanceName = name
259 }
260 }
261 let serverThreads = await api.threads()
262 let tierRows = await api.tiers()
263 // nil = fetch failed (keep what we hold); "" = genuinely never set.
264 let nickFetch = await nickFetchAsync
265 let prefsFetch = await prefsFetchAsync
266 let gates = await gatesAsync
267 if botsEnabled != gates.botsEnabled { botsEnabled = gates.botsEnabled }
268 if appsEnabled != gates.appsEnabled { appsEnabled = gates.appsEnabled }
269 // Route unfurls through the instance's preferred fetch service. nil =
270 // the gates read failed — keep the routing we hold (a blip must not
271 // reroute); "" is a real answer (the instance says unfurl locally).
272 // The registry copy is what reaches LChatAPI values built elsewhere
273 // (ChatFeedModel constructs its own — see FetchService).
274 if let fetchURL = gates.fetchURL {
275 fetchServiceURL = fetchURL
276 if let host = instance.baseURL?.host {
277 FetchService.shared.setURL(fetchURL, forInstanceHost: host)
278 }
279 }
280 // nil = fetch failed — keep the held set rather than "unblocking"
281 // everyone over a network blip.
282 if let blocked = await blocksAsync, blockedPeople != Set(blocked) {
283 blockedPeople = Set(blocked)
284 }
285
286 // ALL crypto in one detached pass — the instance-key unwrap, roster
287 // labels, per-thread ECDH unwraps, previews, titles, app ids. refresh()
288 // fires on any instance activity via the live sockets, so the main
289 // actor must only publish the finished rows (each unwrap alone is a
290 // milliseconds-scale P-256 agreement; each preview/title an AES open).
291 let inst = instance
292 let uid = myUserId
293 // Only adopt a server nicknames copy at least as new as what we hold —
294 // a slow refresh that read BEFORE our save landed must not stomp it.
295 let nickBlob = (nickFetch != nil && nickFetch!.version >= nicknamesVersion)
296 ? nickFetch?.blob : nil
297 let heldNicknames = nicknames
298 let pendingOps = nicknameOps
299 // The prefs blob (muted people) rides the same rules: adopt only a
300 // copy at least as new as ours; nil (failed fetch) keeps what we hold.
301 let prefsBlob = (prefsFetch != nil && prefsFetch!.version >= prefsVersion)
302 ? prefsFetch?.blob : nil
303 let heldMuted = mutedPeople
304 let pendingMutedOps = mutedOps
305 // Snapshot the app catalogue on the main actor (AppRegistry is
306 // MainActor state; the fold runs detached) — titles, glyphs, and
307 // no-activity blurbs all come from it now.
308 let manifests = registry.manifests
309 let folded = await Task.detached(priority: .userInitiated) {
310 Self.fold(instance: inst, myUserId: uid, peopleIn: people,
311 wrappedInstanceKey: wrappedInstanceKey, serverThreads: serverThreads,
312 nicknamesBlob: nickBlob, heldNicknames: heldNicknames,
313 pendingOps: pendingOps, prefsBlob: prefsBlob,
314 heldMuted: heldMuted, pendingMutedOps: pendingMutedOps,
315 tierRows: tierRows, manifests: manifests)
316 }.value
317 instanceKeyB64 = folded.instanceKeyB64
318 // Keyless self-heal (#1376): while the key exists but no copy is
319 // wrapped to us, keep prodding online holders — a keyless member
320 // renders everyone as "Someone" until the grant lands.
321 updateKeylessNudge(keyExists: !(instanceKeyState?.have.isEmpty ?? true))
322 // A name accepted while keyless (setScreenName / the welcome flow's
323 // name step) publishes the moment the key arrives; cleared only once
324 // the server verifiably took the blob, so a failed post retries on
325 // the next pass.
326 if let ik = instanceKeyB64, let parked = PendingLabel.peek(instanceID: instance.id),
327 await publishLabel(parked, ikB64: ik) {
328 PendingLabel.clear(instanceID: instance.id)
329 // The pass that just folded read the roster BEFORE this publish —
330 // queue a trailing pass so the fresh label lands in the rows.
331 Task { [weak self] in await self?.refresh() }
332 }
333 tierKeysB64 = folded.tierKeysB64
334 tiers = tierRows
335 // A level we belong to whose key hasn't reached this device: prod the
336 // holders (keyless), mirroring the web's loadTiers auto-nudge.
337 for row in tierRows where (row.mine ?? false)
338 && (row.wrapped ?? "").isEmpty && !(row.have ?? []).isEmpty {
339 let id = row.id
340 Task { await api.nudgeTier(id) }
341 }
342 // Publish only real changes: most activity-driven refreshes fold to
343 // the exact rows already showing (e.g. a message in the open chat its
344 // own feed already rendered), and an unchanged publish still costs a
345 // full list diff + reconfigure and a chat-screen body pass on main.
346 if roster != folded.people { roster = folded.people }
347 verifyInviteBindings() // KT: check any invitees we hold bind keys for (#573)
348 rosterLabels = folded.rosterLabels
349 nicknames = folded.nicknames
350 if let nf = nickFetch, nf.version >= nicknamesVersion { nicknamesVersion = nf.version }
351 displayLabels = folded.displayLabels
352 mutedPeople = folded.mutedPeople
353 if let pf = prefsFetch, pf.version >= prefsVersion { prefsVersion = pf.version }
354 pushMutedPeople()
355 // A save that failed earlier left ops queued — retry off this refresh
356 // (the web twin's queueNickRetry; without it a rename could be lost).
357 if !nicknameOps.isEmpty { queueNickRetry(delay: 0) }
358 if !mutedOps.isEmpty { queuePrefsRetry(delay: 0) }
359 // Hand the fresh names to the native feed models (live and parked) —
360 // bubble headers resolve senders by uid, my name for them winning.
361 NativeFeedModelCache.shared.updateSenderNames(instance: instance.id,
362 labels: folded.displayLabels)
363 // And the @mention index the bubbles style "@Name" runs against:
364 // published labels to MATCH (a mention travels as text every member
365 // resolves), my names to DISPLAY, yourself bold-but-not-tappable —
366 // the web chatHost's mentionNames/mentionDisplay/mentionable in one.
367 NativeFeedModelCache.shared.updateMentionIndex(
368 instance: instance.id, index: currentMentionIndex())
369 // Park every member's decrypted profile photo (downscaled) in the
370 // shared cache — the way the fold parks thread keys in
371 // SharedThreadKeys — so the notification extension can show a sender's
372 // face on the banner. Off-main; only changed photos actually fetch.
373 let origin = instance.origin
374 let avatars = folded.people.map { ($0.user_id, avatarSource(for: $0)) }
375 Task.detached(priority: .utility) {
376 await SharedAvatarCache.sync(origin: origin, avatars: avatars)
377 }
378 for (tid, mark) in folded.readMarks {
379 serverReadMarks[tid] = max(serverReadMarks[tid] ?? 0, mark)
380 // Forward-only fold into the device-local watermark. Moved here from
381 // the detached fold() so every UserDefaults write stays on the main
382 // actor (see fold()); mark == 0 means the server had no watermark.
383 if mark > ReadState.lastSeen(instance: instance.id, thread: tid) {
384 ReadState.setLastSeen(instance: instance.id, thread: tid, at: mark)
385 }
386 }
387 // A zero-thread fetch against rows we already hold is almost certainly
388 // a failed/offline fetch (the API calls degrade to empty) — keep what
389 // we have and let the next refresh reconcile, rather than blanking a
390 // list the member is looking at (and its snapshot with it).
391 if !folded.rows.isEmpty || threads.isEmpty {
392 if threads != folded.rows {
393 // Debug tap (Mac's ThreadDebug window): surface every unread
394 // flip a refresh causes, with the old and new rows — the dot
395 // is recomputed here from server truth + the local watermark,
396 // so a "phantom" flip is only diagnosable at this seam.
397 if let tap = Self.debugUnreadChanged {
398 let old = Dictionary(uniqueKeysWithValues: threads.map { ($0.id, $0) })
399 for row in folded.rows {
400 if let prev = old[row.id], prev.unread != row.unread {
401 tap(instance, prev, row)
402 }
403 }
404 }
405 threads = folded.rows
406 }
407 }
408 // Park the rows for next time: the hydrate() above is what makes
409 // re-entering a space instant across launches. Off-main, dedup-skipped
410 // (refresh refires on every socket frame; Keychain writes aren't free).
411 if !folded.rows.isEmpty {
412 let name = instanceName
413 let rows = folded.rows
414 Task.detached(priority: .utility) {
415 ThreadListSnapshot.save(origin: origin, name: name, rows: rows)
416 }
417 }
418 fillPreviews(folded.previewQueries)
419 // Space-wide installs: one full pass once the instance key is known —
420 // a cheap no-op on every later refresh (space_thread prods and the
421 // space thread's live frames carry changes from then on).
422 let ik = instanceKeyB64
423 Task { await spaceApps.loadIfNeeded(instanceKeyB64: ik) }
424 // The catalogue's live report needs a page that runs the app framework
425 // (custom + member apps only exist there) — an all-native session may
426 // never open one, so mint the command host for any thread once per
427 // launch (the doc's "appCatalog() is thread-independent, any live
428 // embed can serve it"). It reports the catalogue when it boots.
429 if !registry.hasLiveReport, !catalogHostKicked,
430 let t = threads.first(where: { !$0.threadKeyB64.isEmpty }) {
431 catalogHostKicked = true
432 _ = CommandHostCache.shared.host(for: instance, thread: t,
433 myUserId: myUserId,
434 instanceKeyB64: instanceKeyB64)
435 }
436 // Connected services (the pickers' service-app gate) — refresh fires
437 // on every socket frame, so throttle the fetch to once a minute.
438 if Date().timeIntervalSince(connectedFetchedAt) > 60 {
439 connectedFetchedAt = Date()
440 Task { [weak self] in
441 guard let self, let api = self.api,
442 let services = await api.connectedServices() else { return }
443 self.registry.updateConnected(services)
444 }
445 }
446 }
447
448 /// A space_thread prod (the content-free "it was just created" frame) or
449 /// any other reason to re-pull: full refetch + refold.
450 public func reloadSpaceApps() async {
451 await spaceApps.reload(instanceKeyB64: instanceKeyB64)
452 }
453
454 // Everything refresh() publishes, computed off the main actor.
455 private struct Folded {
456 var instanceKeyB64: String?
457 var tierKeysB64: [String: String] // level id -> unwrapped key (ours)
458 var people: [RosterEntry]
459 var rosterLabels: [String: String]
460 // Per-viewer names: the opened map, and labels with my names resolved
461 // in (what titles, sender headers, and member lists display).
462 var nicknames: [String: NicknameEntry]
463 var displayLabels: [String: String]
464 // Muted people (#578), opened from the sealed prefs blob with the
465 // pending ops replayed — the nicknames treatment, mirrored.
466 var mutedPeople: Set<String>
467 var rows: [ChatThread]
468 // Raw server read watermarks by thread — merged into serverReadMarks
469 // on the main actor (the fold runs detached and can't touch it).
470 var readMarks: [String: Int64]
471 // Threads whose last event is an app event: the app's preview hook
472 // gets the final say, answered async by the app host.
473 var previewQueries: [PreviewQuery]
474 }
475
476 struct PreviewQuery: Sendable {
477 let threadID: String
478 let at: Int64
479 let eventJSON: String
480 let you: Bool
481 }
482
483 nonisolated private static func fold(instance: SavedInstance, myUserId: String,
484 peopleIn: [RosterEntry],
485 wrappedInstanceKey: String?,
486 serverThreads: [ServerThread],
487 nicknamesBlob: String?,
488 heldNicknames: [String: NicknameEntry],
489 pendingOps: [String: NicknameEntry?],
490 prefsBlob: String? = nil,
491 heldMuted: Set<String> = [],
492 pendingMutedOps: [String: Bool] = [:],
493 tierRows: [TierRow] = [],
494 manifests: [String: AppManifest] = [:]) -> Folded {
495 var people = peopleIn
496 var instanceKeyB64: String?
497 var instanceGroupKey: GroupKey?
498 if let wrapped = wrappedInstanceKey,
499 let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv) {
500 instanceKeyB64 = raw.base64urlEncodedString()
501 instanceGroupKey = try? GroupKey(rawKey: raw)
502 }
503 // Our access-level keys: the same ECIES wrap as the instance key, one
504 // per level we hold — they unwrap gated groups' open_keys below.
505 var tierKeysB64: [String: String] = [:]
506 var tierGroupKeys: [String: GroupKey] = [:]
507 for row in tierRows {
508 guard let w = row.wrapped, !w.isEmpty,
509 let raw = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: instance.userPriv)
510 else { continue }
511 tierKeysB64[row.id] = raw.base64urlEncodedString()
512 tierGroupKeys[row.id] = try? GroupKey(rawKey: raw)
513 }
514
515 // Screen names arrive encrypted under the instance key (label_blob); the
516 // server blanks the plaintext `label` once a name is set that way, so
517 // without this the sidebar shows people (and DM titles) as empty. Decrypt
518 // into `.label` — bots / not-yet-migrated people keep their plaintext one.
519 // Mirrors decryptRosterLabels in web/static/chat.js.
520 if let ik = instanceGroupKey {
521 for i in people.indices {
522 guard let blob = people[i].label_blob, !blob.isEmpty,
523 let name = try? ik.decryptString(blob: blob), !name.isEmpty else { continue }
524 people[i].label = name
525 }
526 }
527 let rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) },
528 uniquingKeysWith: { a, _ in a })
529 // Per-viewer names (#577): open my sealed overrides (a fetch failure
530 // keeps what this store already held — never "forget" names on a
531 // blip), then resolve every label through them for display surfaces.
532 var nicknames = heldNicknames
533 if let blob = nicknamesBlob {
534 if blob.isEmpty {
535 nicknames = [:]
536 } else if let opened = try? Nicknames.open(blobB64: blob, privPkcs8B64: instance.userPriv) {
537 nicknames = opened
538 }
539 }
540 // Replay this device's not-yet-landed ops on top of the server copy —
541 // a rename whose save failed must not visibly revert (it stays applied
542 // here and the queued save retries until it lands).
543 for (uid, entry) in pendingOps {
544 if let entry { nicknames[uid] = entry } else { nicknames.removeValue(forKey: uid) }
545 }
546 let displayLabels = rosterLabels.reduce(into: [String: String]()) { out, kv in
547 out[kv.key] = Nicknames.displayName(nicknames[kv.key], label: kv.value)
548 }
549 // Muted people (#578): the same adopt-or-hold + replay dance as the
550 // nicknames above — a blip never reads as "nobody muted", and a mute
551 // whose save hasn't landed yet must not visibly unmute on refresh.
552 var mutedPeople = heldMuted
553 if let blob = prefsBlob {
554 if blob.isEmpty {
555 mutedPeople = []
556 } else if let opened = try? ModPrefs.open(blobB64: blob, privPkcs8B64: instance.userPriv) {
557 mutedPeople = opened
558 }
559 }
560 for (uid, on) in pendingMutedOps {
561 if on { mutedPeople.insert(uid) } else { mutedPeople.remove(uid) }
562 }
563 // Member profile-photo blob ids, for DM/self avatars (decrypted under the
564 // instance key, like the labels above). Groups use their own thread key.
565 let rosterAvatars = Dictionary(
566 people.compactMap { e in e.avatar.map { (e.user_id, $0) } },
567 uniquingKeysWith: { a, _ in a })
568
569 // A member's profile photo (instance key) — DM/self rows and the picker.
570 func memberAvatar(_ blobID: String?) -> AvatarSource? {
571 guard let blobID, !blobID.isEmpty, let key = instanceKeyB64,
572 let base = instance.baseURL else { return nil }
573 return AvatarSource(blobID: blobID, keyB64: key, baseURL: base,
574 token: instance.deviceToken)
575 }
576
577 var rows: [ChatThread] = []
578 var readMarks: [String: Int64] = [:]
579 var previewQueries: [PreviewQuery] = []
580 for t in serverThreads {
581 // Two key sources: the normal per-member ECIES wrap, or — for an
582 // open community group joined self-serve, which has no per-member
583 // copy — open_key, the thread key encrypted under the instance key
584 // (mirrors loadThreads in web/static/shell.js). No usable key →
585 // can't decrypt anything about this thread; skip it.
586 var rawKey: Data?
587 if let wrapped = t.wrapped, !wrapped.isEmpty {
588 rawKey = try? ECIES.unwrap(wrappedB64: wrapped,
589 privPkcs8B64: instance.userPriv)
590 }
591 // A true fallback, not an else: a bad wrapped blob must not hide
592 // an open group the instance key can decrypt.
593 if rawKey == nil, let openKey = t.open_key, !openKey.isEmpty {
594 // The wrapping key follows the gate: a level-gated group's
595 // open_key rides under that tier's key, an ungated one under
596 // the instance key (mirrors loadThreads in shell.js).
597 let src = (t.open_tier?.isEmpty == false) ? tierGroupKeys[t.open_tier!] : instanceGroupKey
598 if let src, let payload = try? src.decryptJSON(OpenKeyPayload.self, blob: openKey) {
599 rawKey = Data(base64urlEncoded: payload.k)
600 }
601 }
602 guard let raw = rawKey else { continue }
603 let key = try? GroupKey(rawKey: raw)
604 let members = t.members ?? []
605 let other = members.first { $0 != myUserId } ?? ""
606
607 // The encrypted name_blob opens ONCE per thread (it used to be
608 // decrypted up to four times — appId, isApp, glyph, then title).
609 // A self-thread's carries {app, title}; a group's carries {name}.
610 var appPayload: AppThreadPayload?
611 var groupName: String?
612 if let nb = t.name_blob, !nb.isEmpty, let key {
613 if t.kind == "self" {
614 appPayload = try? key.decryptJSON(AppThreadPayload.self, blob: nb)
615 } else if t.kind != "dm" {
616 groupName = (try? key.decryptJSON(GroupNamePayload.self, blob: nb))?.name
617 }
618 }
619 let app: String? = (appPayload?.app?.isEmpty == false) ? appPayload?.app : nil
620
621 let title: String
622 switch t.kind {
623 case "self":
624 // Apps take their name from the registry catalogue, so renaming
625 // an app (e.g. Connected services → Integrations) reaches
626 // existing threads too — matching the web, where displayName()
627 // also reads the catalog title. Unknown apps keep the stored
628 // title; a bare self-thread is "Notes".
629 if let app { title = manifests[app]?.title ?? appPayload?.title ?? app.capitalized }
630 else { title = "Notes" }
631 case "dm":
632 // displayLabels: the DM titles by MY name for the person (#577).
633 title = (displayLabels[other]?.isEmpty == false) ? displayLabels[other]! : "Someone"
634 default:
635 if let n = groupName, !n.isEmpty {
636 title = n
637 } else {
638 // Unnamed group: title from the other members' names — mirrors
639 // autoGroupTitle in web/static/format.js exactly.
640 let names = members.filter { $0 != myUserId }
641 .compactMap { displayLabels[$0] }.filter { !$0.isEmpty }
642 title = autoGroupTitle(names)
643 }
644 }
645
646 var preview = ""
647 var lastMine = false
648 if let lb = t.last_blob, !lb.isEmpty, let key,
649 let p = try? key.decryptJSON(MessagePayload.self, blob: lb) {
650 // A chat whose latest message is ours isn't unread — sending from
651 // any device means we've read it. Old payloads have no uid; fall
652 // back to the screen name (mirrors ownMessage in web/static/ui.js).
653 if let uid = p.uid, !myUserId.isEmpty {
654 lastMine = uid == myUserId
655 } else if let n = p.name, !n.isEmpty {
656 lastMine = n == rosterLabels[myUserId]
657 }
658 if p.isRenderableChat || p.isReactionEvent {
659 // displayLabels: my name for them (#577) — the same by-uid
660 // resolution the web's previewText does, so a reaction from
661 // a device that stamped no screen name still names its
662 // sender instead of "Someone".
663 let actor = p.uid.flatMap { displayLabels[$0] } ?? ""
664 preview = previewText(kind: t.kind, payload: p, mine: lastMine, actor: actor)
665 } else {
666 // An APP EVENT (docs/native-apps.md voice surfaces): the
667 // raw payload text is app data, not a sidebar line ("buy
668 // milk" isn't a preview; "James added “buy milk” to the
669 // to-dos" is). Fast path: the chat projection, actor-
670 // prefixed like the web's default chain; the app's own
671 // preview hook answers async through the app host
672 // (fillPreviews) and overrides this.
673 if let proj = ChatFeedModel.renderable(lb, key: key,
674 group: t.kind == "group",
675 myUserId: myUserId) {
676 let text = proj.text ?? ""
677 if proj.action == true, t.kind != "group" {
678 preview = "\(lastMine ? "You" : (p.name ?? "Someone")) \(text)"
679 } else {
680 preview = text // group action lines carry the actor already
681 }
682 }
683 if let data = try? key.decryptData(blob: lb),
684 let json = String(data: data, encoding: .utf8) {
685 previewQueries.append(PreviewQuery(threadID: t.id, at: t.last_at ?? 0,
686 eventJSON: json, you: lastMine))
687 }
688 }
689 }
690 // An app thread with no activity yet shows its blurb instead of a
691 // blank, vertically-centred title (the row layout top-aligns the
692 // moment there's a subtitle) — same intent as the web sidebar.
693 if preview.isEmpty, let app,
694 let blurb = manifests[app]?.blurb, !blurb.isEmpty {
695 preview = blurb
696 }
697
698 // The list chip, mirroring the web's badges: APP for a mini-app
699 // thread, AI/BOT for a DM whose other member is a helper
700 // (shell.js agentTag: kind "ai" = AI, otherwise BOT).
701 var badge: String?
702 if app != nil {
703 badge = "APP"
704 } else if t.kind == "dm",
705 let entry = people.first(where: { $0.user_id == other }),
706 entry.isAgent {
707 badge = entry.isAI ? "AI" : "BOT"
708 }
709
710 // A thread row's avatar: a group's own photo (thread key) or, for a
711 // DM/self chat, the person's profile photo (instance key) — the web
712 // list's group→threadAvatarFor, dm/self→avatarFor split. App threads
713 // show the app's emoji face instead — a self-thread's member photo
714 // (your own) would be wrong there.
715 var avatar: AvatarSource?
716 if app == nil {
717 switch t.kind {
718 case "group":
719 if let blob = t.avatar, !blob.isEmpty, let base = instance.baseURL {
720 avatar = AvatarSource(blobID: blob, keyB64: raw.base64urlEncodedString(),
721 baseURL: base, token: instance.deviceToken)
722 }
723 case "self":
724 avatar = memberAvatar(rosterAvatars[myUserId])
725 default: // dm
726 avatar = memberAvatar(rosterAvatars[other])
727 }
728 }
729
730 let lastAt = t.last_at ?? 0
731 // Fold the server's read watermark (advanced by ANY of this user's
732 // devices) into the local one — reading a chat on the phone clears
733 // the dot here too. The raw value rides back in Folded.readMarks;
734 // the actual ReadState (UserDefaults) write — forward-only, like the
735 // server: a stale local value advances, a newer one this device set
736 // stands — happens back on the MAIN ACTOR in refresh(), never here.
737 // fold() runs on a detached thread, and UserDefaults is NOT safe
738 // against concurrent writes from another thread: a background write
739 // racing a main-thread one (e.g. IntegrityMonitor's save) corrupts
740 // CFPrefs' internal hash mid-encode → CFBasicHash crash. Keep every
741 // UserDefaults write on the main actor. (serverReadMarks is likewise
742 // main-actor state.)
743 readMarks[t.id] = t.last_read_at ?? 0
744 // Park the key + decrypted title for the notification extension: a
745 // push's ciphertext is decrypted on-device against this store —
746 // plus the NICKNAMED members' names, so banners speak MY names for
747 // people (#577). Only overridden names park: for everyone else the
748 // payload's own snapshot is fresher than a fold-time roster label.
749 let memberNames = Dictionary(uniqueKeysWithValues:
750 members.compactMap { m in
751 nicknames[m] != nil ? displayLabels[m].map { (m, $0) } : nil
752 })
753 SharedThreadKeys.save(threadID: t.id, entry: .init(
754 key: raw, title: title, origin: instance.origin, group: t.kind == "group",
755 myUid: myUserId, names: memberNames.isEmpty ? nil : memberNames,
756 muted: t.muted ?? false))
757 rows.append(ChatThread(
758 id: t.id,
759 kind: t.kind,
760 title: title,
761 preview: preview,
762 lastAt: lastAt,
763 unread: Self.isUnread(lastAt: lastAt, lastMine: lastMine,
764 seen: ReadState.lastSeen(instance: instance.id, thread: t.id),
765 manualUnreadAt: ReadState.manualUnreadAt(instance: instance.id, thread: t.id)),
766 lastMine: lastMine,
767 pinned: t.pinned ?? false,
768 hidden: t.hidden ?? false,
769 dmClosed: t.dm_closed ?? false,
770 muted: t.muted,
771 members: members,
772 threadKeyB64: raw.base64urlEncodedString(),
773 avatar: avatar,
774 isApp: app != nil,
775 appId: app,
776 glyph: app.map { manifests[$0]?.glyph ?? "▦" },
777 badge: badge,
778 // The header's status line: yours on the Notes row, the other
779 // member's on a DM; groups/apps carry none (web parity).
780 presence: {
781 guard app == nil else { return nil }
782 let uid = t.kind == "self" ? myUserId : (t.kind == "dm" ? other : "")
783 guard !uid.isEmpty else { return nil }
784 return people.first { $0.user_id == uid }?.presence
785 }()
786 ))
787 }
788 // Pinned chats first, then most-recent activity — mirrors the web list.
789 // Closed (hidden) chats stay in the array — ThreadListView drops them from
790 // the default view but reveals them in search, so they can be reopened.
791 rows.sort(by: order)
792 return Folded(instanceKeyB64: instanceKeyB64, tierKeysB64: tierKeysB64, people: people,
793 rosterLabels: rosterLabels, nicknames: nicknames,
794 displayLabels: displayLabels, mutedPeople: mutedPeople,
795 rows: rows, readMarks: readMarks, previewQueries: previewQueries)
796 }
797
798 // MARK: - Per-viewer names (#577)
799
800 /// The resolved uid → display-name map (my names winning), for the
801 /// webview embed bridge: the page holds no key that could open the
802 /// nicknames blob, so the shell hands it names, never key material.
803 public func displayLabelMap() -> [String: String] { displayLabels }
804
805 /// The name to show for a member — my name for them, their published
806 /// name, then the caller's fallback. The LIVE maps resolve first: between
807 /// a setNickname and its refresh the fold-derived cache is one name stale.
808 public func displayName(for uid: String) -> String {
809 let live = Nicknames.displayName(nicknames[uid], label: rosterLabels[uid])
810 if !live.isEmpty { return live }
811 return displayLabels[uid] ?? ""
812 }
813
814 public func displayName(for entry: RosterEntry) -> String {
815 let name = Nicknames.displayName(nicknames[entry.user_id], label: entry.label)
816 return name.isEmpty ? entry.label : name
817 }
818
819 /// The feed's @mention index (Mentions.swift): matching stays on
820 /// PUBLISHED labels — a mention travels as text every member must
821 /// resolve, so "@MyNickname" would highlight for no one else — while the
822 /// chip shows this viewer's name for them (#577). Your own label indexes
823 /// as not-tappable: a self-mention is a plain bold chip, like the web.
824 public func currentMentionIndex() -> MentionIndex {
825 MentionIndex(entries: roster.map { e in
826 .init(label: e.label, uid: e.user_id,
827 display: displayName(for: e),
828 tappable: e.user_id != myUserId)
829 })
830 }
831
832 /// The web picker's rosterMatches, natively: case-insensitive substring on
833 /// the published label OR my nickname for them (displayName), so the filter
834 /// finds people by whichever name the picker row shows. An empty query
835 /// passes everyone through — the pickers filter unconditionally.
836 public func rosterMatches(_ entries: [RosterEntry], _ query: String) -> [RosterEntry] {
837 guard !query.isEmpty else { return entries }
838 return entries.filter {
839 $0.label.localizedCaseInsensitiveContains(query)
840 || displayName(for: $0).localizedCaseInsensitiveContains(query)
841 }
842 }
843
844 /// The unacknowledged rename for a member, if any (see Nicknames.renameNotice).
845 public func renameNotice(for uid: String) -> (from: String, to: String)? {
846 Nicknames.renameNotice(nicknames[uid], label: rosterLabels[uid])
847 }
848
849 /// Set (or with "" clear) my name for someone, stamping `seen` with their
850 /// current published label so a later rename of theirs prompts me exactly
851 /// once. Returns whether the save landed (false = applied locally, op
852 /// queued, retried by queueNickRetry / the next refresh — never lost
853 /// silently). Mirrors setNickname in web/static/shell.js.
854 @discardableResult
855 public func setNickname(uid: String, name: String) async -> Bool {
856 let entry = name.isEmpty ? nil : NicknameEntry(name: name, seen: rosterLabels[uid] ?? "")
857 if let entry { nicknames[uid] = entry } else { nicknames.removeValue(forKey: uid) }
858 nicknameOps[uid] = entry
859 let ok = await saveNicknames()
860 await refresh() // titles/senders/previews re-fold with the new name
861 return ok
862 }
863
864 /// Acknowledge a rename: keep my name but stop prompting (keep=true), or
865 /// drop my name and follow theirs from now on (keep=false).
866 @discardableResult
867 public func resolveRename(uid: String, keep: Bool) async -> Bool {
868 guard let nick = nicknames[uid] else { return true }
869 let entry = keep ? NicknameEntry(name: nick.name, seen: rosterLabels[uid] ?? nick.seen) : nil
870 if let entry { nicknames[uid] = entry } else { nicknames.removeValue(forKey: uid) }
871 nicknameOps[uid] = entry
872 let ok = await saveNicknames()
873 await refresh()
874 return ok
875 }
876
877 // Saves are serialized on a task chain — two quick renames must not
878 // interleave their read/POST pairs (the web's nickSaveChain, mirrored).
879 private var nickSaveChain: Task<Bool, Never>?
880
881 private func saveNicknames() async -> Bool {
882 let prev = nickSaveChain
883 let task = Task { [weak self] () -> Bool in
884 _ = await prev?.value
885 guard let self else { return false }
886 return await self.saveNicknamesNow()
887 }
888 nickSaveChain = task
889 return await task.value
890 }
891
892 /// Read-modify-write with compare-and-swap: re-read the server blob,
893 /// replay this device's pending ops on top, seal, store. A version
894 /// conflict (another device wrote in between) re-reads and retries; on
895 /// failure the ops stay queued and queueNickRetry keeps trying (and every
896 /// refresh re-drives it), so a rename is never lost silently. Mirrors
897 /// saveNicknamesNow in web/static/shell.js.
898 private func saveNicknamesNow() async -> Bool {
899 guard let api, !nicknameOps.isEmpty else { return nicknameOps.isEmpty }
900 let priv = instance.userPriv
901 let pub = instance.userPub
902 for _ in 0..<3 {
903 guard let (blob, version) = await api.nicknames() else { break }
904 let included = nicknameOps
905 // The ECIES open + seal are milliseconds-scale P-256 work — off
906 // the main actor, like the fold's crypto.
907 let sealed = await Task.detached(priority: .userInitiated) { () -> String? in
908 var merged: [String: NicknameEntry] = [:]
909 if !blob.isEmpty {
910 guard let opened = try? Nicknames.open(blobB64: blob, privPkcs8B64: priv) else {
911 return nil // never overwrite a blob we can't read
912 }
913 merged = opened
914 }
915 for (uid, entry) in included {
916 if let entry { merged[uid] = entry } else { merged.removeValue(forKey: uid) }
917 }
918 return try? Nicknames.seal(merged, recipientPubB64: pub)
919 }.value
920 guard let sealed else { break }
921 switch await api.postNicknames(blob: sealed, version: version) {
922 case .ok(let v):
923 nicknamesVersion = v
924 for (uid, entry) in included {
925 if let entry { nicknames[uid] = entry } else { nicknames.removeValue(forKey: uid) }
926 }
927 // Clear only what this save wrote — an op re-set mid-save stays.
928 for (uid, entry) in included where nicknameOps[uid] == entry {
929 nicknameOps.removeValue(forKey: uid)
930 }
931 return true
932 case .conflict:
933 continue // fresh read next loop; the replay converges
934 case .failed:
935 break
936 }
937 break
938 }
939 queueNickRetry()
940 return false
941 }
942
943 // A failed save must not be a silent loss: keep trying while ops are
944 // queued (the web's queueNickRetry). One retry in flight at a time.
945 private var nickRetryScheduled = false
946 private func queueNickRetry(delay: UInt64 = 5) {
947 guard !nickRetryScheduled, !nicknameOps.isEmpty else { return }
948 nickRetryScheduled = true
949 Task { [weak self] in
950 try? await Task.sleep(nanoseconds: delay * 1_000_000_000)
951 guard let self else { return }
952 self.nickRetryScheduled = false
953 if !self.nicknameOps.isEmpty, await self.saveNicknames() {
954 await self.refresh()
955 }
956 }
957 }
958
959 // MARK: - Reports (#578)
960
961 /// Who a report can be sealed to: roster members holding the admin role,
962 /// with a public key to seal to, people only (the web's exact filter in
963 /// openReportSheet). Bots never adjudicate reports.
964 public var reportableAdmins: [RosterEntry] {
965 roster.filter { $0.role == "admin" && !$0.public_key.isEmpty && !$0.isAgent }
966 }
967
968 /// POST the sealed report blobs (ReportSheet builds + seals them; the
969 /// store just owns the API handle). `blobs` is [{admin_id, blob}].
970 public func fileReport(reportedID: String, threadID: String,
971 blobs: [[String: String]]) async -> LChatAPI.ReportResult {
972 guard let api else { return .failed }
973 return await api.fileReport(reportedID: reportedID, threadID: threadID, blobs: blobs)
974 }
975
976 // MARK: - Muted people (#578)
977
978 /// Mute or unmute a person, applying locally at once (the sync is
979 /// eventual, like the web's setPersonMuted). Their messages in shared
980 /// chats fold to a quiet stub and their pushes go passive; they never
981 /// find out, and the server can't read the muted set either. Returns
982 /// whether the save landed (false = applied locally, op queued, retried —
983 /// never lost silently).
984 @discardableResult
985 public func setPersonMuted(uid: String, muted on: Bool) async -> Bool {
986 guard !uid.isEmpty, uid != myUserId else { return false }
987 if on { mutedPeople.insert(uid) } else { mutedPeople.remove(uid) }
988 mutedOps[uid] = on
989 pushMutedPeople()
990 return await savePrefs()
991 }
992
993 // The live muted set, fanned out to every consumer that can't observe
994 // this store: the parked/live feed models (their stubs re-fold off the
995 // published set) and the NSE's shared park. Called whenever the set
996 // changes — a local toggle, a save's merge, or a refresh's server copy.
997 private func pushMutedPeople() {
998 NativeFeedModelCache.shared.updateMutedPeople(instance: instance.id, muted: mutedPeople)
999 let origin = instance.origin
1000 let set = mutedPeople
1001 Task.detached(priority: .utility) {
1002 SharedMutedPeople.save(origin: origin, uids: set)
1003 }
1004 }
1005
1006 // Saves are serialized like the nicknames chain — two quick mutes must
1007 // not interleave their read/POST pairs.
1008 private var prefsSaveChain: Task<Bool, Never>?
1009
1010 private func savePrefs() async -> Bool {
1011 let prev = prefsSaveChain
1012 let task = Task { [weak self] () -> Bool in
1013 _ = await prev?.value
1014 guard let self else { return false }
1015 return await self.savePrefsNow()
1016 }
1017 prefsSaveChain = task
1018 return await task.value
1019 }
1020
1021 /// Read-modify-write with compare-and-swap — saveNicknamesNow's exact
1022 /// shape over the prefs blob: re-read, replay this device's pending ops,
1023 /// seal to my own key, POST with the version read. A conflict re-reads
1024 /// and retries; a failure leaves the ops queued for queuePrefsRetry.
1025 /// Mirrors saveModPrefsNow in web/static/shell.js.
1026 private func savePrefsNow() async -> Bool {
1027 guard let api, !mutedOps.isEmpty else { return mutedOps.isEmpty }
1028 let priv = instance.userPriv
1029 let pub = instance.userPub
1030 for _ in 0..<3 {
1031 guard let (blob, version) = await api.prefs() else { break }
1032 let included = mutedOps
1033 // ECIES open + seal off the main actor, like the nicknames save.
1034 let built = await Task.detached(priority: .userInitiated) { () -> (sealed: String, merged: Set<String>)? in
1035 var merged: Set<String> = []
1036 if !blob.isEmpty {
1037 guard let opened = try? ModPrefs.open(blobB64: blob, privPkcs8B64: priv) else {
1038 return nil // never overwrite a blob we can't read
1039 }
1040 merged = opened
1041 }
1042 for (uid, on) in included {
1043 if on { merged.insert(uid) } else { merged.remove(uid) }
1044 }
1045 guard let sealed = try? ModPrefs.seal(merged, recipientPubB64: pub) else { return nil }
1046 return (sealed, merged)
1047 }.value
1048 guard let built else { break }
1049 switch await api.postPrefs(blob: built.sealed, version: version) {
1050 case .ok(let v):
1051 prefsVersion = v
1052 mutedPeople = built.merged // adopt another device's writes too
1053 // Clear only what this save wrote — an op re-set mid-save stays.
1054 for (uid, on) in included where mutedOps[uid] == on {
1055 mutedOps.removeValue(forKey: uid)
1056 }
1057 pushMutedPeople()
1058 return true
1059 case .conflict:
1060 continue // fresh read next loop; the replay converges
1061 case .failed:
1062 break
1063 }
1064 break
1065 }
1066 queuePrefsRetry()
1067 return false
1068 }
1069
1070 // The nicknames retry, mirrored: a failed prefs save keeps trying while
1071 // ops are queued (and every refresh re-drives it). One retry at a time.
1072 private var prefsRetryScheduled = false
1073 private func queuePrefsRetry(delay: UInt64 = 5) {
1074 guard !prefsRetryScheduled, !mutedOps.isEmpty else { return }
1075 prefsRetryScheduled = true
1076 Task { [weak self] in
1077 try? await Task.sleep(nanoseconds: delay * 1_000_000_000)
1078 guard let self else { return }
1079 self.prefsRetryScheduled = false
1080 if !self.mutedOps.isEmpty {
1081 _ = await self.savePrefs()
1082 }
1083 }
1084 }
1085
1086 // MARK: - Blocked people (#578)
1087
1088 /// Block or unblock a person (the web's setPersonBlocked). The server
1089 /// records no-new-contact and, on block, closes any existing DM — we
1090 /// mirror both at once (its dm_closed frame will confirm). Block implies
1091 /// mute, so their existing messages fold away too; unblocking leaves the
1092 /// mute AND the closed DM in place — undoing each is its own choice.
1093 /// Server-first, so a refusal changes nothing locally and the profile
1094 /// card can say "Couldn't save" honestly.
1095 @discardableResult
1096 public func setPersonBlocked(uid: String, on: Bool) async -> Bool {
1097 guard !uid.isEmpty, uid != myUserId, let api else { return false }
1098 guard await (on ? api.block(uid: uid) : api.unblock(uid: uid)) else { return false }
1099 if on {
1100 blockedPeople.insert(uid)
1101 // Block implies you stop seeing them — fire-and-forget like the
1102 // web: the mute applies locally at once inside setPersonMuted;
1103 // its sealed-prefs save syncs on its own clock.
1104 Task { await setPersonMuted(uid: uid, muted: true) }
1105 if let dm = threads.first(where: { $0.kind == "dm" && $0.members.contains(uid) }) {
1106 setDMClosedLocally(dm.id, true) // the server just closed it; mirror at once
1107 }
1108 } else {
1109 blockedPeople.remove(uid) // the mute stays until they unmute — a separate choice
1110 }
1111 return true
1112 }
1113
1114 /// Remove a member from the space — or remove AND ban their key from
1115 /// re-registering (issue #578). Admin only; the profile card gates on
1116 /// isAdmin as a nicety and the server re-checks the role. Server-first,
1117 /// then a full refresh so the roster and threads repaint without them.
1118 @discardableResult
1119 public func removeFromSpace(uid: String, ban: Bool) async -> Bool {
1120 guard !uid.isEmpty, uid != myUserId, let api else { return false }
1121 guard await api.adminRemoveUser(uid: uid, ban: ban) else { return false }
1122 // Your DM with them (if any) is a husk now — close it like Close
1123 // chat, so it drops off the list instead of lingering headed by a
1124 // ghost (matches the web's remove dialog).
1125 if let dm = threads.first(where: { $0.kind == "dm" && $0.members.contains(uid) }) {
1126 setHidden(dm, true)
1127 }
1128 await refresh()
1129 return true
1130 }
1131
1132 // Pinned chats first, then most-recent activity — mirrors the web list.
1133 nonisolated public static func order(_ a: ChatThread, _ b: ChatThread) -> Bool {
1134 (a.pinned != b.pinned) ? a.pinned : a.lastAt > b.lastAt
1135 }
1136
1137 // Whose presence a row's dot shows: yours on the Notes/self row, the other
1138 // member's on a DM; groups (and app threads) carry none — matching the web
1139 // list (shell.js renderThreadList).
1140 private func presenceUserID(kind: String, members: [String]) -> String? {
1141 switch kind {
1142 case "self": return myUserId.isEmpty ? nil : myUserId
1143 case "dm": return members.first { $0 != myUserId }
1144 default: return nil
1145 }
1146 }
1147
1148 /// Roster facts for every member of a chat except the viewer — feeds the
1149 /// filter rules and the "· N online" count (Presence.swift).
1150 public func memberPresence(of t: ChatThread) -> [MemberPresence] {
1151 t.members.filter { $0 != myUserId }.map { id in
1152 let e = roster.first { $0.user_id == id }
1153 return MemberPresence(agent: e?.isAgent ?? false, presence: e?.presence)
1154 }
1155 }
1156
1157 /// The filter menu's verdict for one chat: its category checkbox must be
1158 /// on, then "Online only" (when set) must keep it.
1159 public func passesFilter(_ t: ChatThread, onlineOnly: Bool, people: Bool, bots: Bool, apps: Bool) -> Bool {
1160 let others = memberPresence(of: t)
1161 let shown = switch chatCategory(isApp: t.isApp, others: others) {
1162 case .people: people
1163 case .bots: bots
1164 case .apps: apps
1165 }
1166 return shown && (!onlineOnly || onlineFilterKeeps(others))
1167 }
1168
1169 /// How many PEOPLE in a chat are online right now — the group header's
1170 /// "· N online" (bots' always-on sockets would say nothing, so they don't
1171 /// count; the web's paintThreadHeader applies the same rule).
1172 public func onlinePeopleCount(of t: ChatThread) -> Int {
1173 memberPresence(of: t).filter { !$0.agent && $0.presence == "online" }.count
1174 }
1175
1176 /// Fold one live "presence" frame (uid → online/away/offline) into the
1177 /// roster and any DM/self row showing that member — the between-refresh
1178 /// counterpart of the stamp refresh() reads off /api/roster.
1179 public func applyPresence(userID: String, state: String) {
1180 // Presence beats repeat the current state constantly — only a real
1181 // transition is worth an objectWillChange (each publish re-renders
1182 // the whole chat list and re-evaluates the pushed chat screen, #465).
1183 if let i = roster.firstIndex(where: { $0.user_id == userID }),
1184 roster[i].presence != state {
1185 roster[i].presence = state
1186 }
1187 for i in threads.indices where !threads[i].isApp
1188 && threads[i].presence != state
1189 && presenceUserID(kind: threads[i].kind, members: threads[i].members) == userID {
1190 threads[i].presence = state
1191 }
1192 }
1193
1194 /// Pin/unpin yourself Away (POST /api/presence). The server broadcasts the
1195 /// transition, so our own rows update via applyPresence like anyone else's.
1196 @discardableResult
1197 public func setAway(_ away: Bool) async -> Bool {
1198 await api?.setAway(away) ?? false
1199 }
1200
1201 /// The pinned Away flag for the settings toggle (nil = couldn't load).
1202 public func loadAway() async -> Bool? {
1203 await api?.away()
1204 }
1205
1206 /// Pin or unpin a chat: update locally (re-sorting so it floats/settles), then
1207 /// tell the server. The flag is per-user and syncs to the user's other devices.
1208 public func setPinned(_ thread: ChatThread, _ pinned: Bool) {
1209 guard let i = threads.firstIndex(where: { $0.id == thread.id }) else { return }
1210 threads[i].pinned = pinned
1211 threads.sort(by: Self.order)
1212 Task { await api?.setPinned(thread: thread.id, pinned: pinned) }
1213 }
1214
1215 /// Mute or unmute a chat: no unread dot, no notifications — the server
1216 /// skips its pushes while muted, and the flag syncs to the user's other
1217 /// devices. Optimistic local flip (ordering deliberately unchanged — a
1218 /// muted chat keeps its place, it just stays quiet), then POST; the
1219 /// follow-up refresh re-parks the SharedThreadKeys entry so the NSE's
1220 /// belt-and-braces gate sees the new state without waiting for activity.
1221 public func setMuted(_ thread: ChatThread, _ muted: Bool) {
1222 guard let i = threads.firstIndex(where: { $0.id == thread.id }) else { return }
1223 threads[i].muted = muted
1224 let id = thread.id
1225 Task {
1226 await api?.setMuted(thread: id, muted: muted)
1227 await refresh()
1228 }
1229 }
1230
1231 /// Close (hidden=true) or reopen (hidden=false) a chat. ThreadListView hides
1232 /// closed chats from the default list; the row stays in the store so search can
1233 /// still surface it. Syncs to the user's other devices via the server flag.
1234 public func setHidden(_ thread: ChatThread, _ hidden: Bool) {
1235 guard let i = threads.firstIndex(where: { $0.id == thread.id }) else { return }
1236 threads[i].hidden = hidden
1237 Task { await api?.setHidden(thread: thread.id, hidden: hidden) }
1238 }
1239
1240 /// Opening a closed chat reopens it (locally + server) — mirrors the web's
1241 /// openThread. A no-op for chats that aren't hidden.
1242 public func reopenIfHidden(_ thread: ChatThread) {
1243 guard let i = threads.firstIndex(where: { $0.id == thread.id }), threads[i].hidden else { return }
1244 setHidden(threads[i], false)
1245 }
1246
1247 /// Open the DM with someone, creating it on first use — the web's
1248 /// openOrCreateDM, behind every "Message them" (the profile card, a
1249 /// tapped @mention). There's exactly one DM per pair, so an existing one
1250 /// reopens (un-hiding a closed row) rather than spawning a second.
1251 /// Returns the thread id to navigate to, or nil — the refusal copy
1252 /// ("They're not accepting messages" when their DM door is closed, #578)
1253 /// is already in loadError, exactly as createChat leaves it.
1254 public func openOrCreateDM(uid: String) async -> String? {
1255 if let dm = threads.first(where: { $0.kind == "dm" && $0.members.contains(uid) }) {
1256 reopenIfHidden(dm)
1257 return dm.id
1258 }
1259 return await createChat(memberIDs: [uid], groupName: nil)
1260 }
1261
1262 // MARK: DM privacy + end conversation (#578)
1263
1264 /// End a DM for BOTH people (the web's endConversation): it drops off both
1265 /// lists and the server refuses new messages until someone reopens it.
1266 /// Optimistic — the row marks closed+hidden immediately (and the parked
1267 /// feed model swaps its composer for the banner); a refused POST puts
1268 /// everything back, so a network blip never fakes a mutual close.
1269 public func endConversation(_ thread: ChatThread) async -> Bool {
1270 guard thread.kind == "dm", let api else { return false }
1271 setDMClosedLocally(thread.id, true)
1272 guard await api.closeDM(thread: thread.id) else {
1273 setDMClosedLocally(thread.id, false)
1274 return false
1275 }
1276 return true
1277 }
1278
1279 /// Reopen an ended DM — for both parties, like ending it. .dmsClosed means
1280 /// the OTHER side's DM door is shut (their choice wins; admins/bots pass
1281 /// server-side), so the banner can say so honestly.
1282 public func reopenDM(_ threadID: String) async -> LChatAPI.ReopenDM {
1283 guard let api else { return .failed }
1284 let r = await api.reopenDM(thread: threadID)
1285 if case .ok = r { setDMClosedLocally(threadID, false) }
1286 return r
1287 }
1288
1289 /// A live dm_closed/dm_reopened frame — either party acted, possibly on
1290 /// another device. Fold the flag straight into the row like presence (no
1291 /// refetch; the frame carries the whole change); closing also hides, the
1292 /// web's WS handler rule.
1293 public func applyDMClosed(threadID: String, closed: Bool) {
1294 guard let i = threads.firstIndex(where: { $0.id == threadID }) else { return }
1295 threads[i].dmClosed = closed
1296 if closed { threads[i].hidden = true }
1297 }
1298
1299 // The shared local fold for our OWN close/reopen: row flag + hidden move
1300 // together (ending hides for both sides; reopening restores the row), and
1301 // the parked feed model learns immediately so an open pane swaps its
1302 // composer/banner without waiting for the echo frame.
1303 private func setDMClosedLocally(_ threadID: String, _ closed: Bool) {
1304 if let i = threads.firstIndex(where: { $0.id == threadID }) {
1305 threads[i].dmClosed = closed
1306 threads[i].hidden = closed
1307 }
1308 NativeFeedModelCache.shared.noteDMClosed(instance: instance.id,
1309 thread: threadID, closed: closed)
1310 }
1311
1312 /// The DM-privacy door for the settings picker ("" / "everyone" /
1313 /// "nobody"; nil = couldn't load — keep the picker gated, like Away).
1314 public func loadDMPrivacy() async -> String? {
1315 await api?.dmPrivacy()
1316 }
1317
1318 /// Set who may start a DM with you — "everyone" or "nobody".
1319 @discardableResult
1320 public func setDMPrivacy(_ value: String) async -> Bool {
1321 await api?.setDMPrivacy(value) ?? false
1322 }
1323
1324 // Start a chat with the given other members (empty = a "Notes" self thread, one =
1325 // DM, more = group). Generates a fresh thread key, wraps it to every member
1326 // MARK: Open groups (community)
1327
1328 /// The community directory, decrypted: every open group with its name and
1329 /// whether we're already in it. nil means the instance key hasn't reached
1330 /// this device yet (the same key-pending state the web's Discover shows);
1331 /// rows whose open_key won't decrypt are dropped — they'd be unjoinable.
1332 /// Mirrors showDiscover in web/static/shell.js.
1333 public func discoverGroups() async -> [DiscoverGroup]? {
1334 guard let api else { return [] }
1335 let rows = await api.openThreads()
1336 if rows.isEmpty { return [] }
1337 guard let ikB64 = instanceKeyB64, let ik = try? GroupKey(base64url: ikB64) else { return nil }
1338 var out: [DiscoverGroup] = []
1339 for row in rows {
1340 // Three states, mirroring the web's showDiscover: joinable (we can
1341 // decrypt), locked to another access level (the name STAYS sealed —
1342 // that's the feature, an honest lock), or ours with the level key
1343 // still on its way to this device.
1344 let gate = row.open_tier ?? ""
1345 let tierName = row.tier_name ?? ""
1346 let joined = row.joined ?? false
1347 // Joined always wins: membership IS access, so a member of a
1348 // gated group they've since left the level of still gets Open.
1349 if !joined && !gate.isEmpty && !(row.eligible ?? false) {
1350 out.append(DiscoverGroup(id: row.id,
1351 title: tierName.isEmpty ? "A limited group" : "A group for \(tierName)",
1352 memberCount: row.member_count ?? 0,
1353 joined: false, defaultJoin: false,
1354 locked: true, tierName: tierName))
1355 continue
1356 }
1357 let srcB64 = gate.isEmpty ? ikB64 : tierKeysB64[gate]
1358 guard let srcB64, let src = try? GroupKey(base64url: srcB64) else {
1359 out.append(DiscoverGroup(id: row.id,
1360 title: tierName.isEmpty ? "Open group" : "A \(tierName) group",
1361 memberCount: row.member_count ?? 0,
1362 joined: joined, defaultJoin: false,
1363 keyPending: !joined, tierName: tierName))
1364 continue
1365 }
1366 guard let payload = try? src.decryptJSON(OpenKeyPayload.self, blob: row.open_key),
1367 let raw = Data(base64urlEncoded: payload.k),
1368 let key = try? GroupKey(rawKey: raw) else {
1369 if joined {
1370 // Undecryptable but ours: keep the row navigable.
1371 out.append(DiscoverGroup(id: row.id,
1372 title: tierName.isEmpty ? "Open group" : "A group for \(tierName)",
1373 memberCount: row.member_count ?? 0,
1374 joined: true, defaultJoin: false, tierName: tierName))
1375 }
1376 continue
1377 }
1378 var title = "Open group"
1379 if let nb = row.name_blob, !nb.isEmpty,
1380 let n = (try? key.decryptJSON(GroupNamePayload.self, blob: nb))?.name, !n.isEmpty {
1381 title = n
1382 }
1383 out.append(DiscoverGroup(id: row.id, title: title,
1384 memberCount: row.member_count ?? 0,
1385 joined: row.joined ?? false,
1386 defaultJoin: row.default_join ?? false,
1387 tierName: tierName))
1388 }
1389 return out
1390 }
1391
1392 /// Self-serve join an open group, then refresh so the thread appears in
1393 /// the list (its key rides open_key — the fold unwraps it via the instance
1394 /// key; no key material moves at join time). False on refusal, e.g. the
1395 /// group was closed while the sheet was up.
1396 public func joinOpenGroup(_ id: String) async -> Bool {
1397 guard let api else { return false }
1398 guard await api.joinThread(id) else { return false }
1399 await refresh()
1400 return true
1401 }
1402
1403 // MARK: Paid access levels (member side)
1404
1405 /// Fetch the access levels fresh (price + this member's subscription state)
1406 /// for the Membership view — bypasses the cached `tiers` snapshot so the
1407 /// sheet always shows current status after a change.
1408 public func membershipTiers() async -> [TierRow] {
1409 guard let api else { return [] }
1410 return await api.tiers()
1411 }
1412
1413 /// Start checkout for a sellable level. .url → send the member to the
1414 /// hosted page; entitlement (and the level key) arrive via the webhook.
1415 public func subscribeTier(_ id: String) async -> LChatAPI.BillingResult {
1416 guard let api else { return .failed }
1417 return await api.subscribeTier(id)
1418 }
1419
1420 /// Switch a subscription to another level in place (prorated). Refreshes on
1421 /// success so the new level's key can be picked up.
1422 public func changeTier(from: String, to: String) async -> LChatAPI.BillingResult {
1423 guard let api else { return .failed }
1424 let r = await api.changeTier(from: from, to: to)
1425 if case .ok = r { await refresh() }
1426 return r
1427 }
1428
1429 /// Cancel at period end — access stays until the paid period runs out.
1430 public func cancelTierSub(_ id: String) async -> LChatAPI.BillingResult {
1431 guard let api else { return .failed }
1432 let r = await api.cancelTierSub(id)
1433 if case .ok = r { await refresh() }
1434 return r
1435 }
1436
1437 /// Resume an ending subscription (un-cancel the same sub, no new charge).
1438 public func resumeTier(_ id: String) async -> LChatAPI.BillingResult {
1439 guard let api else { return .failed }
1440 let r = await api.resumeTier(id)
1441 if case .ok = r { await refresh() }
1442 return r
1443 }
1444
1445 /// Open the Stripe billing portal for this level's subscription.
1446 public func tierPortal(_ id: String) async -> LChatAPI.BillingResult {
1447 guard let api else { return .failed }
1448 return await api.tierPortal(id)
1449 }
1450
1451 // (including us) via ECIES, encrypts the group name, and creates the thread; the
1452 // server dedupes DMs. Returns the thread id (refreshing the list), or nil on error.
1453 // `group` = the caller's explicit intent (the group flow was used) — the
1454 // web's createThread(opts.group) rule, so a one-other-person group is a
1455 // real kind:"group" (its name and shared apps stick), never a deduped DM.
1456 // `apps` (groups only) seeds the new group's shared apps: one encrypted
1457 // groupapps.add event each, posted into the fresh log exactly as the web's
1458 // ⋮ → Add apps emits them — so every member (web included) folds the same list.
1459 // `openGroup` makes a community group: the thread key is additionally
1460 // encrypted under the instance key (open_key) so any member can discover
1461 // and self-join — requires the instance key to have reached this device.
1462 public func createChat(memberIDs others: [String], groupName: String?,
1463 group: Bool = false, apps: [String] = [],
1464 openGroup: Bool = false) async -> String? {
1465 guard let api, !myUserId.isEmpty else {
1466 loadError = "Couldn't determine your identity yet — try again in a moment."
1467 return nil
1468 }
1469 if openGroup && instanceKeyB64 == nil {
1470 loadError = "Your key hasn't arrived yet — try again in a moment."
1471 return nil
1472 }
1473 var ids = Set(others)
1474 ids.insert(myUserId)
1475
1476 // Public keys for everyone in the thread (ourselves from the instance keypair).
1477 var pubs = [myUserId: instance.userPub]
1478 for e in roster where ids.contains(e.user_id) { pubs[e.user_id] = e.public_key }
1479 guard ids.allSatisfy({ pubs[$0] != nil }) else {
1480 loadError = "Couldn't find a key for everyone selected."
1481 return nil
1482 }
1483
1484 let kind = others.isEmpty ? "self" : (group || others.count > 1 ? "group" : "dm")
1485 let name = groupName?.trimmingCharacters(in: .whitespaces)
1486 let memberIDs = ids
1487 let memberPubs = pubs
1488 let seedApps = kind == "group" ? apps : []
1489 // Apps gating content behind an openEvent (board) get it fired right
1490 // after their add, like the web's installGroupApp — captured on the
1491 // main actor (the registry is MainActor state; the build is detached).
1492 let openEvents = Dictionary(uniqueKeysWithValues: seedApps.compactMap { app in
1493 (registry.manifest(app)?.openEvent).flatMap { $0.isEmpty ? nil : (app, $0) }
1494 })
1495 let screenName = instance.screenName
1496 let uid = myUserId
1497 let openIKB64 = (openGroup && kind == "group") ? instanceKeyB64 : nil
1498 // Key generation, the name encrypt, one ECDH wrap PER MEMBER and the
1499 // seed-app event encrypts — a big group is many milliseconds; all of
1500 // it happens off main.
1501 let built: (nameBlob: String, members: [[String: String]], appBlobs: [String], openKey: String)? =
1502 await Task.detached(priority: .userInitiated) {
1503 let raw = ECIES.randomAESKey()
1504 let key = try? GroupKey(rawKey: raw)
1505 var nameBlob = ""
1506 if kind == "group", let n = name, !n.isEmpty, let key {
1507 nameBlob = (try? key.encryptJSON(["name": n])) ?? ""
1508 }
1509 // The open_key blob: {k: <thread key>} under the instance key —
1510 // the same shape the web's createThread writes.
1511 var openKey = ""
1512 if let ikB64 = openIKB64, let ik = try? GroupKey(base64url: ikB64) {
1513 openKey = (try? ik.encryptJSON(["k": raw.base64urlEncodedString()])) ?? ""
1514 }
1515 if openIKB64 != nil && openKey.isEmpty { return nil }
1516 var members: [[String: String]] = []
1517 for id in memberIDs {
1518 guard let pub = memberPubs[id],
1519 let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: pub)
1520 else { return nil }
1521 members.append(["user_id": id, "wrapped": wrapped])
1522 }
1523 // The web's emit envelope around {type:"groupapps.add", app}:
1524 // stamped with name, ts (ms) and uid so every client folds and
1525 // attributes it identically.
1526 var appBlobs: [String] = []
1527 if let key {
1528 let ts = Int64(Date().timeIntervalSince1970 * 1000)
1529 for app in seedApps {
1530 var payload: [String: Any] = ["type": "groupapps.add", "app": app,
1531 "name": screenName, "ts": ts]
1532 if !uid.isEmpty { payload["uid"] = uid }
1533 if let data = try? JSONSerialization.data(withJSONObject: payload),
1534 let blob = try? key.encryptBytes(data).base64urlEncodedString() {
1535 appBlobs.append(blob)
1536 }
1537 if let ev = openEvents[app] {
1538 var open: [String: Any] = ["type": ev, "name": screenName, "ts": ts]
1539 if !uid.isEmpty { open["uid"] = uid }
1540 if let data = try? JSONSerialization.data(withJSONObject: open),
1541 let blob = try? key.encryptBytes(data).base64urlEncodedString() {
1542 appBlobs.append(blob)
1543 }
1544 }
1545 }
1546 }
1547 return (nameBlob, members, appBlobs, openKey)
1548 }.value
1549 guard let built else {
1550 loadError = "Couldn't wrap the chat key."
1551 return nil
1552 }
1553
1554 let tid: String
1555 switch await api.createThread(kind: kind, nameBlob: built.nameBlob,
1556 members: built.members, openKey: built.openKey) {
1557 case .success(let id):
1558 tid = id
1559 case .failure(.dmsClosed):
1560 // Their DM door is shut (issue #578) — honest copy, inline where
1561 // the picker already shows loadError, not a generic failure.
1562 loadError = "They're not accepting messages"
1563 return nil
1564 case .failure:
1565 loadError = "Couldn't start the chat. Try again."
1566 return nil
1567 }
1568 // Seed the shared apps into the fresh log BEFORE anyone opens it, so
1569 // the first open already folds them. Best-effort: a lost event just
1570 // means adding the app again from the group's menu.
1571 for blob in built.appBlobs {
1572 await api.postMessage(thread: tid, blob: blob)
1573 }
1574 await refresh()
1575 return tid
1576 }
1577
1578 /// App-level tap for a chat being marked read. The core stays
1579 /// platform-neutral: iOS points this at PushManager.clearDelivered (so
1580 /// reading a chat clears its delivered notifications, PR #260); the Mac
1581 /// has no per-thread notification-centre entries yet.
1582 public static var onMarkRead: ((String) -> Void)?
1583
1584 /// Debug tap: a refresh flipped a row's unread state (old row, new row).
1585 /// Wired by the Mac's Debug-build event windows (ThreadDebug.swift);
1586 /// nil everywhere else. Called on the main actor.
1587 public static var debugUnreadChanged: ((SavedInstance, ChatThread, ChatThread) -> Void)?
1588
1589 // The server's last-known read watermark per thread, so markRead only POSTs
1590 // when it actually advances the synced mark (opening an already-read chat,
1591 // or a refresh-tail re-mark, stays a local no-op).
1592 private var serverReadMarks: [String: Int64] = [:]
1593
1594 // The one unread rule, shared by refresh() and applyReadFrame(): someone
1595 // else's message newer than the watermark — or an explicit device-local
1596 // mark-as-unread of exactly the current newest message (a newer arrival or
1597 // the watermark passing it retires the manual flag naturally). The manual
1598 // clause needs a real flag (> 0): 0 means "none", and a thread with no
1599 // messages has lastAt 0 too, so a bare == made every EMPTY thread read as
1600 // unread on every refresh — the App Studio phantom-dot bug (opening
1601 // cleared the dot in place; the next refresh recomputed it back on).
1602 nonisolated static func isUnread(lastAt: Int64, lastMine: Bool, seen: Int64, manualUnreadAt: Int64) -> Bool {
1603 !lastMine && (lastAt > seen || (manualUnreadAt > 0 && manualUnreadAt == lastAt))
1604 }
1605
1606 /// Clear a thread's unread state once it's opened — locally, and via the
1607 /// server to the user's other devices ("read here, read everywhere").
1608 public func markRead(_ thread: ChatThread) {
1609 ReadState.setManualUnread(instance: instance.id, thread: thread.id, at: 0)
1610 ReadState.setLastSeen(instance: instance.id, thread: thread.id, at: thread.lastAt)
1611 // Publish only a real flip — markRead re-fires on every arrival while
1612 // the chat is open and on every open of an already-read chat (#465).
1613 if let i = threads.firstIndex(where: { $0.id == thread.id }), threads[i].unread {
1614 threads[i].unread = false
1615 }
1616 Self.onMarkRead?(thread.id)
1617 if thread.lastAt > (serverReadMarks[thread.id] ?? 0) {
1618 serverReadMarks[thread.id] = thread.lastAt
1619 let (tid, at) = (thread.id, thread.lastAt)
1620 Task { await api?.setRead(thread: tid, lastReadAt: at) }
1621 }
1622 }
1623
1624 /// markRead's toggle partner: flag the newest message as deliberately
1625 /// unread again. Device-local by design — the server mark is forward-only
1626 /// (and the web has no mark-unread), so the flag lives beside the watermark
1627 /// rather than in it and survives the server fold on refresh.
1628 public func markUnread(_ thread: ChatThread) {
1629 ReadState.setManualUnread(instance: instance.id, thread: thread.id, at: thread.lastAt)
1630 if let i = threads.firstIndex(where: { $0.id == thread.id }) { threads[i].unread = true }
1631 }
1632
1633 /// A "read" frame — this user's watermark advanced on another of their
1634 /// devices (the server routes these to the owner's connections only). Fold
1635 /// it in and clear the row's dot if the mark covers the newest message.
1636 public func applyReadFrame(threadID: String, lastReadAt: Int64) {
1637 serverReadMarks[threadID] = max(serverReadMarks[threadID] ?? 0, lastReadAt)
1638 if lastReadAt > ReadState.lastSeen(instance: instance.id, thread: threadID) {
1639 ReadState.setLastSeen(instance: instance.id, thread: threadID, at: lastReadAt)
1640 }
1641 guard let i = threads.firstIndex(where: { $0.id == threadID }),
1642 threads[i].unread, threads[i].lastAt <= lastReadAt else { return }
1643 // Reading it anywhere trumps a manual unread left on this device.
1644 ReadState.setManualUnread(instance: instance.id, thread: threadID, at: 0)
1645 threads[i].unread = false
1646 }
1647
1648 // App titles, glyphs, and no-activity blurbs all come from the registry
1649 // catalogue now (AppRegistry — the seed generated from the web apps by
1650 // tools/gen-app-catalog.mjs, superseded by live appCatalog() reports),
1651 // so nothing here needs keeping in sync per app anymore. The former
1652 // appTitles / appGlyphs / appDescriptions / personalApps tables collapsed
1653 // into registry reads (docs/native-apps.md phase 2).
1654
1655 /// Add people to a group: wrap the EXISTING thread key to each newcomer's
1656 /// public key and register them (mirrors the web's member sheet). Returns
1657 /// the ids actually added.
1658 public func addMembers(_ thread: ChatThread, userIDs: [String]) async -> [String] {
1659 guard let api, let raw = Data(base64urlEncoded: thread.threadKeyB64) else { return [] }
1660 let pubs = Dictionary(roster.map { ($0.user_id, $0.public_key) },
1661 uniquingKeysWith: { a, _ in a })
1662 // All the ECDH wraps in one detached hop; only the registration calls
1663 // run up here.
1664 let wraps: [(id: String, wrapped: String)] =
1665 await Task.detached(priority: .userInitiated) {
1666 userIDs.compactMap { id in
1667 guard let pub = pubs[id],
1668 let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: pub)
1669 else { return nil }
1670 return (id, wrapped)
1671 }
1672 }.value
1673 var added: [String] = []
1674 for w in wraps {
1675 guard await api.addMember(thread: thread.id, userID: w.id, wrapped: w.wrapped)
1676 else { continue }
1677 added.append(w.id)
1678 }
1679 if !added.isEmpty { await refresh() }
1680 return added
1681 }
1682
1683 /// Invite a brand-new person straight into a DM (the web's inviteSomeone):
1684 /// generate the DM key, wrap it to US (so we hold it) and to the invite
1685 /// secret (so the joiner recovers it); the server stores only blobs. The
1686 /// secret rides ONLY in the returned link's #fragment.
1687 public func inviteSomeone(name: String) async -> (link: String?, error: String?) {
1688 guard let api else { return (nil, "not connected") }
1689 guard let ikB64 = instanceKeyB64, let ik = try? GroupKey(base64url: ikB64) else {
1690 return (nil, "still setting up — try again in a moment")
1691 }
1692 let t = InviteCrypto.randomSecret()
1693 let userPub = instance.userPub
1694 // Key derivation + two wraps + two encrypts, off main.
1695 let built: (id: String, wrapped: String, selfWrapped: String,
1696 labelBlob: String, claimHash: String)? =
1697 await Task.detached(priority: .userInitiated) {
1698 let raw = ECIES.randomAESKey()
1699 let b64 = raw.base64urlEncodedString()
1700 guard let derived = try? InviteCrypto.derive(t),
1701 let selfWrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: userPub),
1702 let wrapped = try? derived.wrapKey.encryptJSON(["threadKey": b64]),
1703 let labelBlob = try? ik.encryptJSON(name) else { return nil }
1704 return (derived.id, wrapped, selfWrapped, labelBlob, derived.claimHash)
1705 }.value
1706 guard let built else { return (nil, "couldn't build the invite") }
1707 let r = await api.createUserInvite(labelBlob: built.labelBlob, id: built.id,
1708 wrapped: built.wrapped, selfWrapped: built.selfWrapped,
1709 claimHash: built.claimHash)
1710 guard r.threadID != nil else { return (nil, r.error) }
1711 // Key transparency (#573): stash the T-derived bind key against the
1712 // invitee's user id. Once they join and publish a key, verifyInviteBindings
1713 // confirms the roster key is the one THEY published, not a substitute.
1714 if let uid = r.userID, let bindKey = try? InviteCrypto.derive(t).bindKey {
1715 rememberInviteBindKey(uid: uid, bindKey: bindKey)
1716 }
1717 await refresh()
1718 let link = instance.origin + "/join/join#" + InviteCrypto.fragment(t, name: name)
1719 return (link, nil)
1720 }
1721
1722 // ---- key transparency: verify-before-wrap (advisory, #573) ----------------
1723 // The inviter is the one member who holds T for a joiner, so only they can
1724 // check the bind-MAC. We stash each invitee's bind key at invite time, and
1725 // once they've joined + published a key, confirm the roster key is the one
1726 // THEY published. Advisory (Paul's call): a mismatch records an integrity
1727 // alert (raising the banner), never blocks. A verified binding stays stashed
1728 // and re-checks every roster load (a later substitution at a new wrap is
1729 // caught too, not just the join window). A pre-KT member with no bind-MAC is
1730 // "unverified", not a failure.
1731 private var inviteBindsKey: String { "lchat_invite_binds_" + instance.id }
1732 private func loadInviteBinds() -> [String: String] {
1733 (UserDefaults.standard.dictionary(forKey: inviteBindsKey) as? [String: String]) ?? [:]
1734 }
1735 private func rememberInviteBindKey(uid: String, bindKey: String) {
1736 var m = loadInviteBinds()
1737 m[uid] = bindKey
1738 UserDefaults.standard.set(m, forKey: inviteBindsKey)
1739 }
1740 private func verifyInviteBindings() {
1741 var m = loadInviteBinds()
1742 guard !m.isEmpty else { return }
1743 let monitor = NativeFeedModelCache.shared.integrityMonitor(for: instance)
1744 var next: [String: String] = [:]
1745 for (uid, bindKey) in m {
1746 guard let e = roster.first(where: { $0.user_id == uid }) else { continue } // gone → drop
1747 next[uid] = bindKey // keep + re-check every load
1748 if e.public_key.isEmpty { continue } // not keyed yet
1749 guard let mac = e.bind_mac, !mac.isEmpty else { continue } // pre-KT: unverifiable, not a failure
1750 if !InviteCrypto.verifyBindMac(bindKeyB64: bindKey, pubB64: e.public_key, macB64: mac) {
1751 monitor.recordAlert(kind: "key-substitution", key: uidToAlertKey(uid))
1752 }
1753 }
1754 if next.count != m.count { UserDefaults.standard.set(next, forKey: inviteBindsKey) }
1755 m = next
1756 }
1757 // The alert dedup discriminator is an Int64; hash the uid stably into one.
1758 private func uidToAlertKey(_ uid: String) -> Int64 {
1759 var h: UInt64 = 1469598103934665603
1760 for b in uid.utf8 { h = (h ^ UInt64(b)) &* 1099511628211 }
1761 return Int64(bitPattern: h)
1762 }
1763
1764 /// How a screen-name change landed: published to everyone, queued for the
1765 /// moment the instance key arrives, or failed outright. `queued` is a
1766 /// success the UI should say honestly ("shows for everyone shortly"), not
1767 /// silently misreport as published (#1376).
1768 public enum SetNameOutcome { case published, queued, failed }
1769
1770 /// Change your display name across the instance: encrypted under the
1771 /// instance key (the web's publishMyLabel). Callers should also persist
1772 /// the new name into the SavedInstance so outgoing payloads carry it.
1773 /// While the key hasn't reached this device (a fresh joiner whose grant
1774 /// is still in flight) the name PARKS instead of silently failing — the
1775 /// refresh that unwraps the granted key publishes it (#1376).
1776 @discardableResult
1777 public func setScreenName(_ name: String) async -> SetNameOutcome {
1778 guard let api else { return .failed }
1779 guard let ikB64 = instanceKeyB64 else {
1780 PendingLabel.park(name, instanceID: instance.id)
1781 await api.nudgeInstanceKey() // hurry the grant along
1782 return .queued
1783 }
1784 guard await publishLabel(name, ikB64: ikB64) else { return .failed }
1785 // A name parked in an earlier keyless moment is superseded — it must
1786 // not overwrite this one when the key arrives.
1787 PendingLabel.clear(instanceID: instance.id)
1788 await refresh()
1789 return .published
1790 }
1791
1792 // The label post itself (crypto off main): true only when the server took
1793 // the blob. Shared by setScreenName and the parked-name publish above.
1794 private func publishLabel(_ name: String, ikB64: String) async -> Bool {
1795 guard let api else { return false }
1796 let blob = await Task.detached(priority: .userInitiated) { () -> String? in
1797 guard let ik = try? GroupKey(base64url: ikB64) else { return nil }
1798 return try? ik.encryptJSON(name)
1799 }.value
1800 guard let blob else { return false }
1801 return await api.postLabel(labelBlob: blob)
1802 }
1803
1804 // ---- keyless self-heal (#1376) -------------------------------------------
1805 // The web nudges persistently (shell.js setupInstanceKey: on boot, on
1806 // focus, and every ~60s) because the first holder to see a nudge may come
1807 // online long after the join. Natively, refresh() already refires on
1808 // foregrounding and socket activity; this loop covers the idle stretches
1809 // in between, polling briefly after each nudge so a grant lands within
1810 // seconds of a holder answering.
1811 private var keylessNudge: Task<Void, Never>?
1812
1813 private func updateKeylessNudge(keyExists: Bool) {
1814 guard instanceKeyB64 == nil, keyExists else {
1815 keylessNudge?.cancel()
1816 keylessNudge = nil
1817 return
1818 }
1819 guard keylessNudge == nil, let api else { return }
1820 keylessNudge = Task { [weak self] in
1821 while !Task.isCancelled, self != nil {
1822 await api.nudgeInstanceKey()
1823 for _ in 0..<8 { // ~20s of quick polls, then back off
1824 try? await Task.sleep(nanoseconds: 2_500_000_000)
1825 if Task.isCancelled || self == nil { return }
1826 if await api.instanceKeyWrapped() != nil {
1827 self?.keylessNudge = nil
1828 await self?.refresh() // unwraps the key, folds labels, publishes any parked name
1829 return
1830 }
1831 }
1832 try? await Task.sleep(nanoseconds: 40_000_000_000) // the web's ~60s cadence
1833 }
1834 }
1835 }
1836
1837 deinit { keylessNudge?.cancel() }
1838
1839 /// A member without the instance key asked for it (an instance_key_nudge
1840 /// frame) — the holder side of the self-heal, mirroring shell.js: never
1841 /// wrap to a key handed to us in a raw frame (a compromised server could
1842 /// forge one and harvest members' names/photos); re-fetch the roster and
1843 /// wrap only to the public key the server has PUBLISHED for that user.
1844 /// An unseen/forged nudge is simply ignored — the joiner re-nudges.
1845 public func handleInstanceKeyNudge(userID: String) {
1846 guard let api, let ikB64 = instanceKeyB64, !userID.isEmpty else { return }
1847 Task { [weak self] in
1848 let people = await api.roster()
1849 guard let target = people.first(where: { $0.user_id == userID && !$0.public_key.isEmpty }),
1850 let raw = Data(base64urlEncoded: ikB64) else { return }
1851 let pub = target.public_key
1852 let wrapped = await Task.detached(priority: .userInitiated) {
1853 try? ECIES.wrap(rawKey: raw, recipientPubB64: pub)
1854 }.value
1855 guard let wrapped else { return }
1856 if await api.grantInstanceKey([["user_id": userID, "wrapped": wrapped]]) {
1857 await self?.refresh() // the joiner may be new to our roster too
1858 }
1859 }
1860 }
1861
1862 /// Rename a group (encrypted under its own thread key, like the web).
1863 @discardableResult
1864 public func renameGroup(_ thread: ChatThread, to name: String) async -> Bool {
1865 guard let api, let raw = Data(base64urlEncoded: thread.threadKeyB64) else { return false }
1866 let blob = await Task.detached(priority: .userInitiated) { () -> String? in
1867 guard let key = try? GroupKey(rawKey: raw) else { return nil }
1868 return try? key.encryptJSON(["name": name])
1869 }.value
1870 guard let blob else { return false }
1871 let ok = await api.setThreadName(thread: thread.id, nameBlob: blob)
1872 if ok { await refresh() }
1873 return ok
1874 }
1875
1876 /// The user's personal thread for a mini-app, created on first use —
1877 /// mirrors shell.js createAppThread: a `self` thread whose name_blob is
1878 /// the ENCRYPTED {app, title}, key wrapped only to us.
1879 public func openOrCreateAppThread(app: String, title: String) async -> String? {
1880 if let existing = threads.first(where: { $0.appId == app }) { return existing.id }
1881 guard let api, !myUserId.isEmpty else { return nil }
1882 let userPub = instance.userPub
1883 let built: (nameBlob: String, wrapped: String)? =
1884 await Task.detached(priority: .userInitiated) {
1885 let raw = ECIES.randomAESKey()
1886 guard let key = try? GroupKey(rawKey: raw),
1887 let nameBlob = try? key.encryptJSON(["app": app, "title": title]),
1888 let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: userPub)
1889 else { return nil }
1890 return (nameBlob, wrapped)
1891 }.value
1892 guard let built,
1893 case .success(let tid) = await api.createThread(
1894 kind: "self", nameBlob: built.nameBlob,
1895 members: [["user_id": myUserId, "wrapped": built.wrapped]]) else {
1896 return nil
1897 }
1898 await refresh()
1899 return tid
1900 }
1901
1902 // "A" / "A & B" / "A, B & C" / "A, B & N more" — keep in lockstep with
1903 // format.js's autoGroupTitle so a chat is called the same thing everywhere.
1904 nonisolated public static func autoGroupTitle(_ names: [String]) -> String {
1905 switch names.count {
1906 case 0: return "Group"
1907 case 1: return names[0]
1908 case 2: return "\(names[0]) & \(names[1])"
1909 case 3: return "\(names[0]), \(names[1]) & \(names[2])"
1910 default: return "\(names[0]), \(names[1]) & \(names.count - 2) more"
1911 }
1912 }
1913}
1914
1915// 🤖 A screen name accepted while the instance key hadn't reached this device
1916// yet (#1376): parked per-instance and published by the first refresh that
1917// holds the key, so "set my name" — from Settings or the welcome flow's name
1918// step — never silently vanishes for a keyless joiner. Just a name, no key
1919// material, so UserDefaults is fine (main-actor writes only, like ReadState).
1920@MainActor
1921enum PendingLabel {
1922 static func key(_ instanceID: String) -> String { "lchat.pendingLabel|" + instanceID }
1923 static func park(_ name: String, instanceID: String) {
1924 UserDefaults.standard.set(name, forKey: key(instanceID))
1925 }
1926 static func peek(instanceID: String) -> String? {
1927 let name = UserDefaults.standard.string(forKey: key(instanceID))
1928 return (name?.isEmpty ?? true) ? nil : name
1929 }
1930 static func clear(instanceID: String) {
1931 UserDefaults.standard.removeObject(forKey: key(instanceID))
1932 }
1933}
1934
1935// Per-thread read state (last-seen message time). Just timestamps — no secrets —
1936// so UserDefaults is fine (the keys + private material stay in the Keychain).
1937// Public: the iOS screen reads the pre-tap watermark to anchor unreads.
1938// The local value is a floor: refresh() folds the server's synced watermark
1939// forward into it, so reads on the user's other devices land here too.
1940public enum ReadState {
1941 private static func key(_ instance: String, _ thread: String) -> String {
1942 "lchat.seen|\(instance)|\(thread)"
1943 }
1944 public static func lastSeen(instance: String, thread: String) -> Int64 {
1945 Int64(UserDefaults.standard.integer(forKey: key(instance, thread)))
1946 }
1947 public static func setLastSeen(instance: String, thread: String, at: Int64) {
1948 UserDefaults.standard.set(Int(at), forKey: key(instance, thread))
1949 }
1950
1951 // A deliberate device-local "mark as unread": the lastAt of the message it
1952 // was applied to (0 = none). Kept beside the watermark, not inside it — the
1953 // server fold is forward-only and would immediately erase a wound-back
1954 // timestamp. Retires itself once anything newer arrives (the stored value
1955 // stops matching the thread's lastAt).
1956 private static func unreadKey(_ instance: String, _ thread: String) -> String {
1957 "lchat.unread|\(instance)|\(thread)"
1958 }
1959 public static func manualUnreadAt(instance: String, thread: String) -> Int64 {
1960 Int64(UserDefaults.standard.integer(forKey: unreadKey(instance, thread)))
1961 }
1962 public static func setManualUnread(instance: String, thread: String, at: Int64) {
1963 if at <= 0 {
1964 UserDefaults.standard.removeObject(forKey: unreadKey(instance, thread))
1965 } else {
1966 UserDefaults.standard.set(Int(at), forKey: unreadKey(instance, thread))
1967 }
1968 }
1969}
1970