| 1 | import Foundation |
| 2 | import 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 |
| 10 | public 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 || p.isThreadConvertEvent { |
| 659 | // displayLabels: my name for them (#577) — the same by-uid |
| 660 | // resolution the web's previewText does, so a reaction (or |
| 661 | // a conversion) from a device that stamped no screen name |
| 662 | // still names its 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 = ThreadStore.threadKind(others: others, group: group) |
| 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 | /// Which `kind` a new chat is created as. The caller's `group` intent |
| 1905 | /// wins outright — an OPEN group legitimately starts with nobody but its |
| 1906 | /// creator (others find it in Discover), and before this an empty member |
| 1907 | /// list made it a kind:"self" thread, i.e. a second "Notes" (the group's |
| 1908 | /// name and open_key silently dropped). Mirrors the web's |
| 1909 | /// createThread(otherIds, {group}), which never derives "self" here. |
| 1910 | nonisolated public static func threadKind(others: [String], group: Bool) -> String { |
| 1911 | if group { return "group" } |
| 1912 | if others.isEmpty { return "self" } |
| 1913 | return others.count > 1 ? "group" : "dm" |
| 1914 | } |
| 1915 | |
| 1916 | nonisolated public static func autoGroupTitle(_ names: [String]) -> String { |
| 1917 | switch names.count { |
| 1918 | case 0: return "Group" |
| 1919 | case 1: return names[0] |
| 1920 | case 2: return "\(names[0]) & \(names[1])" |
| 1921 | case 3: return "\(names[0]), \(names[1]) & \(names[2])" |
| 1922 | default: return "\(names[0]), \(names[1]) & \(names.count - 2) more" |
| 1923 | } |
| 1924 | } |
| 1925 | } |
| 1926 | |
| 1927 | // 🤖 A screen name accepted while the instance key hadn't reached this device |
| 1928 | // yet (#1376): parked per-instance and published by the first refresh that |
| 1929 | // holds the key, so "set my name" — from Settings or the welcome flow's name |
| 1930 | // step — never silently vanishes for a keyless joiner. Just a name, no key |
| 1931 | // material, so UserDefaults is fine (main-actor writes only, like ReadState). |
| 1932 | @MainActor |
| 1933 | enum PendingLabel { |
| 1934 | static func key(_ instanceID: String) -> String { "lchat.pendingLabel|" + instanceID } |
| 1935 | static func park(_ name: String, instanceID: String) { |
| 1936 | UserDefaults.standard.set(name, forKey: key(instanceID)) |
| 1937 | } |
| 1938 | static func peek(instanceID: String) -> String? { |
| 1939 | let name = UserDefaults.standard.string(forKey: key(instanceID)) |
| 1940 | return (name?.isEmpty ?? true) ? nil : name |
| 1941 | } |
| 1942 | static func clear(instanceID: String) { |
| 1943 | UserDefaults.standard.removeObject(forKey: key(instanceID)) |
| 1944 | } |
| 1945 | } |
| 1946 | |
| 1947 | // Per-thread read state (last-seen message time). Just timestamps — no secrets — |
| 1948 | // so UserDefaults is fine (the keys + private material stay in the Keychain). |
| 1949 | // Public: the iOS screen reads the pre-tap watermark to anchor unreads. |
| 1950 | // The local value is a floor: refresh() folds the server's synced watermark |
| 1951 | // forward into it, so reads on the user's other devices land here too. |
| 1952 | public enum ReadState { |
| 1953 | private static func key(_ instance: String, _ thread: String) -> String { |
| 1954 | "lchat.seen|\(instance)|\(thread)" |
| 1955 | } |
| 1956 | public static func lastSeen(instance: String, thread: String) -> Int64 { |
| 1957 | Int64(UserDefaults.standard.integer(forKey: key(instance, thread))) |
| 1958 | } |
| 1959 | public static func setLastSeen(instance: String, thread: String, at: Int64) { |
| 1960 | UserDefaults.standard.set(Int(at), forKey: key(instance, thread)) |
| 1961 | } |
| 1962 | |
| 1963 | // A deliberate device-local "mark as unread": the lastAt of the message it |
| 1964 | // was applied to (0 = none). Kept beside the watermark, not inside it — the |
| 1965 | // server fold is forward-only and would immediately erase a wound-back |
| 1966 | // timestamp. Retires itself once anything newer arrives (the stored value |
| 1967 | // stops matching the thread's lastAt). |
| 1968 | private static func unreadKey(_ instance: String, _ thread: String) -> String { |
| 1969 | "lchat.unread|\(instance)|\(thread)" |
| 1970 | } |
| 1971 | public static func manualUnreadAt(instance: String, thread: String) -> Int64 { |
| 1972 | Int64(UserDefaults.standard.integer(forKey: unreadKey(instance, thread))) |
| 1973 | } |
| 1974 | public static func setManualUnread(instance: String, thread: String, at: Int64) { |
| 1975 | if at <= 0 { |
| 1976 | UserDefaults.standard.removeObject(forKey: unreadKey(instance, thread)) |
| 1977 | } else { |
| 1978 | UserDefaults.standard.set(Int(at), forKey: unreadKey(instance, thread)) |
| 1979 | } |
| 1980 | } |
| 1981 | } |
| 1982 | |