iOS+Mac: per-viewer names — native parity (#577) (#654)
* ios+mac: per-viewer names — native parity for #577 LChatCore: Nicknames.swift twins ui.js displayNameFor/renameNotice; ECIES.seal/open (sealForPub's byte-compatible twins) open + write the sealed blob; ThreadStore folds my names into titles, sender headers, member lists, and the shared-keychain entries the NSE reads; saves are journaled read-modify-write with compare-and-swap retries. UI (iOS first, Mac same change): member rows open the shared MemberProfileSheet (rename + keep/adopt live there); RenameBanner sits under the DM header on NativeChatScreen, ChatView, and NativeChatView; composer typeahead matches the name you call someone (inserts stay published); the webview embeds receive a resolved uid→name map (names only — never key material) which shell.js folds in as display-only overrides. Tests: NicknamesTests (pure twins) + NicknamesVectorTests against a web-emitted fixture (test/nicknames.test.mjs, both directions + a wrong-key refusal). No changelog — native entries wait for the release mint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: nicknames vector gate rides npm test * ios+mac: review fixes — error-vs-empty fetch, save retry + ops replay, embed banner/staleness, DM rename entry - LChatAPI.nicknames() checks the HTTP status (a JSON error body no longer masquerades as 'never set' and wipes every nickname) - The fold replays this device's pending ops on the server copy, refresh only adopts a copy at least as new as held (a slow pre-save read can't stomp a landed rename), failed saves retry on a 5s loop AND on every refresh — a rename is never lost or visibly reverted - Saves serialize on a task chain (the web's nickSaveChain) and their ECIES open/seal runs off the main actor - displayName(for uid:) resolves the live maps before the fold cache (a fresh rename showed stale for one refresh) - The web rename banner never renders inside a native webview (native chrome owns the prompt; an embed tap could only fake-succeed), and the native shell pushes fresh name maps into cached webviews (__lchatSetNames) so parked pages don't freeze names - DM headers open the profile card on all three surfaces (iOS ChatView + NativeChatScreen, Mac feedHeader) — the first-nickname path exists natively, not just via groups - NSE parks only NICKNAMED members' names (the payload snapshot stays authoritative for everyone else); ECIES.wrap dedups through seal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
25 files changed,
+883
−37
ios/LChat/FeedWebCache.swift+24 −0ios/LChat/Views/ChatView.swift+29 −2ios/LChat/Views/GroupSheets.swift+23 −7ios/LChat/Views/NativeChatScreen.swift+31 −2ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift+7 −3ios/LChatCore/Sources/LChatCore/Crypto.swift+24 −3ios/LChatCore/Sources/LChatCore/LChatAPI.swift+40 −0ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift+123 −0ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift+13 −0ios/LChatCore/Sources/LChatCore/Nicknames.swift+59 −0ios/LChatCore/Sources/LChatCore/RenameBanner.swift+45 −0ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift+5 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+210 −8ios/LChatCore/Tests/LChatCoreTests/Fixtures/nicknames-vector.json+20 −0ios/LChatCore/Tests/LChatCoreTests/NicknamesTests.swift+32 −0ios/LChatCore/Tests/LChatCoreTests/NicknamesVectorTests.swift+42 −0ios/LChatMac/AppThreadWebView.swift+3 −0ios/LChatMac/CommandPanelWebView.swift+3 −0ios/LChatMac/GroupSheets.swift+21 −7ios/LChatMac/NativeChatView.swift+23 −0ios/LChatMac/NativeFeedView.swift+1 −1ios/LChatNSE/NotificationService.swift+11 −3package.json+1 −1test/nicknames.test.mjs+75 −0web/static/shell.js+18 −0
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift| index 3cdb446..e46a89b 100644 |
| --- a/ios/LChat/FeedWebCache.swift |
| +++ b/ios/LChat/FeedWebCache.swift |
| @@ -45,6 +45,25 @@ final class FeedWebCache { |
| ) { _ in |
| Task { @MainActor in FeedWebCache.shared.dropParked() } |
| } |
| + // The boot seed's names map is baked once at webview creation, but |
| + // parked webviews outlive renames (and a pre-first-refresh open bakes |
| + // an empty map) — push every fresh fold's names into the live pages. |
| + NativeFeedModelCache.shared.onNamesUpdate = { [weak self] instanceID, names in |
| + self?.pushNames(instanceID: instanceID, names: names) |
| + } |
| + } |
| + |
| + // Hand a fresh resolved uid → name map to every cached webview of an |
| + // instance (shell.js __lchatSetNames). Names only — never key material. |
| + private func pushNames(instanceID: String, names: [String: String]) { |
| + guard !names.isEmpty, |
| + let data = try? JSONSerialization.data(withJSONObject: names), |
| + let json = String(data: data, encoding: .utf8) else { return } |
| + for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| + entry.web.evaluateJavaScript( |
| + "window.__lchatSetNames && window.__lchatSetNames(\(json)); undefined;", |
| + completionHandler: nil) |
| + } |
| } |
| // The live webview for this thread — reused if we have it, created (and left |
| @@ -151,6 +170,11 @@ final class FeedWebCache { |
| seed["view"] = "interface" |
| } |
| if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| + // Per-viewer names (#577): the resolved uid → name map, so the page |
| + // shows my names for people. Names only — never key material (the |
| + // embed can't open the nicknames blob, by design). |
| + let names = NativeFeedModelCache.shared.names(instance: instance.id) |
| + if !names.isEmpty { seed["names"] = names } |
| guard let data = try? JSONSerialization.data(withJSONObject: seed), |
| let json = String(data: data, encoding: .utf8) else { return nil } |
| // json is a JSON object literal (valid JS). Assigning it as the flag means |
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift| index 7dfa374..7cbfb4b 100644 |
| --- a/ios/LChat/Views/ChatView.swift |
| +++ b/ios/LChat/Views/ChatView.swift |
| @@ -36,6 +36,9 @@ struct ChatView: View { |
| @State private var fullFrame: CGRect = .zero // the full-bleed webview, global coords |
| @State private var safeFrame: CGRect = .zero // the safe region between the bars |
| @State private var avatarPop = false // header avatar springs to full size once the pane lands |
| + // The DM partner's profile card (rename them for yourself there — #577). |
| + @State private var profileUid: ProfileTarget? |
| + struct ProfileTarget: Identifiable { let id: String } |
| // The native typeahead (token under the caret + ranked candidates) — |
| // computed locally, no webview round-trip. |
| @State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])? |
| @@ -98,6 +101,13 @@ struct ChatView: View { |
| // pill's top edge slightly). The offset lifts the whole stack up into the (now |
| // transparent) bar so the avatar's top lines up with the back button / ⋯. The |
| // pill's material + explicit colour keep the name legible over any message. |
| + // The DM partner's uid — the header opens their profile card (rename them |
| + // for yourself there, #577), like tapping the web header title. |
| + private var dmPartner: String? { |
| + guard thread.kind == "dm" else { return nil } |
| + return thread.members.first { $0 != store.myUserId } |
| + } |
| + |
| private var chatHeader: some View { |
| VStack(spacing: -5) { |
| Avatar(name: thread.title, size: 54, source: thread.avatar) |
| @@ -115,6 +125,10 @@ struct ChatView: View { |
| .padding(.bottom, 6) |
| .offset(y: -52) // rise up level with the top of the back button / ⋯ menu |
| .accessibilityElement(children: .combine) |
| + .contentShape(Rectangle()) |
| + .onTapGesture { if let uid = dmPartner { profileUid = ProfileTarget(id: uid) } } |
| + .accessibilityAddTraits(dmPartner != nil ? .isButton : []) |
| + .accessibilityHint(dmPartner != nil ? "Shows their profile" : "") |
| } |
| var body: some View { |
| @@ -187,7 +201,19 @@ struct ChatView: View { |
| // pop, and no title-colour flash. Its own offset lifts it into the bar; the |
| // feed's top inset (pushInsets) still grows to clear its laid-out height. |
| .safeAreaInset(edge: .top, spacing: 0) { |
| - if !isMac { chatHeader } |
| + VStack(spacing: 0) { |
| + if !isMac { chatHeader } |
| + // A DM partner renamed themself past my name for them (#577): |
| + // the keep/adopt choice, inline under the header. |
| + RenameBanner(store: store, thread: thread) |
| + } |
| + } |
| + .sheet(item: $profileUid) { target in |
| + MemberProfileSheet(store: store, uid: target.id) { e in |
| + Avatar(name: store.displayName(for: e), size: 72, |
| + source: store.avatarSource(for: e)) |
| + } |
| + .presentationDetents([.medium]) |
| } |
| .toolbar { |
| // Top-right overflow: the companion-app view switch (named for |
| @@ -346,7 +372,8 @@ struct ChatView: View { |
| // web's commandCatalog(threadId). The webview runs whatever's picked. |
| let items = typeaheadItems(token, roster: store.roster, |
| myUserId: store.myUserId, hasBot: hasBot, |
| - appCommands: AppDefaultCommand.localCommands(appID: thread.appId)) |
| + appCommands: AppDefaultCommand.localCommands(appID: thread.appId), |
| + displayNames: store.displayLabelMap()) |
| nativeTypeahead = items.isEmpty ? nil : (token, items) |
| } |
diff --git a/ios/LChat/Views/GroupSheets.swift b/ios/LChat/Views/GroupSheets.swift| index 8ed8ee7..5c708f1 100644 |
| --- a/ios/LChat/Views/GroupSheets.swift |
| +++ b/ios/LChat/Views/GroupSheets.swift |
| @@ -14,6 +14,9 @@ struct MembersSheet: View { |
| @State private var selected: Set<String> = [] |
| @State private var busy = false |
| @State private var note: String? |
| + // A tapped member's profile card — rename them for yourself there (#577). |
| + @State private var profile: ProfileTarget? |
| + struct ProfileTarget: Identifiable { let id: String } |
| private var members: [RosterEntry] { |
| thread.members.compactMap { id in store.roster.first { $0.user_id == id } } |
| @@ -52,6 +55,13 @@ struct MembersSheet: View { |
| .toolbar { |
| ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } |
| } |
| + .sheet(item: $profile) { target in |
| + MemberProfileSheet(store: store, uid: target.id) { e in |
| + Avatar(name: store.displayName(for: e), size: 72, |
| + source: store.avatarSource(for: e)) |
| + } |
| + .presentationDetents([.medium]) |
| + } |
| .safeAreaInset(edge: .bottom) { |
| if !selected.isEmpty { |
| Button { |
| @@ -88,16 +98,22 @@ struct MembersSheet: View { |
| private func row(_ person: RosterEntry, selectable: Bool) -> some View { |
| Button { |
| - guard selectable else { return } |
| - if selected.contains(person.user_id) { |
| - selected.remove(person.user_id) |
| - } else { |
| - selected.insert(person.user_id) |
| + if selectable { |
| + if selected.contains(person.user_id) { |
| + selected.remove(person.user_id) |
| + } else { |
| + selected.insert(person.user_id) |
| + } |
| + } else if person.user_id != store.myUserId { |
| + // A member's row opens their profile card — where "call them |
| + // something else" and the rename notice live (#577). |
| + profile = ProfileTarget(id: person.user_id) |
| } |
| } label: { |
| HStack(spacing: 10) { |
| - Avatar(name: person.label, size: 32, source: store.avatarSource(for: person)) |
| - Text(person.label).foregroundStyle(.primary).lineLimit(1) |
| + Avatar(name: store.displayName(for: person), size: 32, |
| + source: store.avatarSource(for: person)) |
| + Text(store.displayName(for: person)).foregroundStyle(.primary).lineLimit(1) |
| if person.isAgent { AgentBadge(person.isAI ? "AI" : "BOT") } |
| Spacer() |
| if selectable { |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 446b7e8..bc0490a 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -158,6 +158,9 @@ struct NativeChatScreen: View { |
| // also the web's touch size), so Settings → Text Size scales the feed. |
| @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17 |
| @State private var avatarPop = false |
| + // The DM partner's profile card (rename them for yourself there — #577). |
| + @State private var headerProfile: HeaderProfileTarget? |
| + struct HeaderProfileTarget: Identifiable { let id: String } |
| // Whether the bottom sentinel is on screen — gates the scroll pins. |
| @State private var bottomVisible = false |
| // The composer inset's measured height — its growth re-pins the feed (see |
| @@ -203,6 +206,13 @@ struct NativeChatScreen: View { |
| store.threads.first { $0.id == thread.id }?.unread ?? false |
| } |
| + // The DM partner's uid — the header opens their profile card (rename them |
| + // for yourself there, #577), like tapping the web header title. |
| + private var dmPartner: String? { |
| + guard thread.kind == "dm" else { return nil } |
| + return thread.members.first { $0 != store.myUserId } |
| + } |
| + |
| // The Messages-style header ChatView established: big avatar level with the |
| // bar buttons, the name in a glass pill tucked under it. |
| private var chatHeader: some View { |
| @@ -235,6 +245,10 @@ struct NativeChatScreen: View { |
| .padding(.bottom, 6) |
| .offset(y: -52) // rise level with the back button / ⋯ menu |
| .accessibilityElement(children: .combine) |
| + .contentShape(Rectangle()) |
| + .onTapGesture { if let uid = dmPartner { headerProfile = HeaderProfileTarget(id: uid) } } |
| + .accessibilityAddTraits(dmPartner != nil ? .isButton : []) |
| + .accessibilityHint(dmPartner != nil ? "Shows their profile" : "") |
| } |
| // A group's shared-app surface: schema-described apps (To-do, Reminders) |
| @@ -275,7 +289,21 @@ struct NativeChatScreen: View { |
| .navigationTitle("") |
| .navigationBarTitleDisplayMode(.inline) |
| .toolbarBackground(.hidden, for: .navigationBar) |
| - .safeAreaInset(edge: .top, spacing: 0) { chatHeader } |
| + .safeAreaInset(edge: .top, spacing: 0) { |
| + VStack(spacing: 0) { |
| + chatHeader |
| + // A DM partner renamed themself past my name for them |
| + // (#577): the keep/adopt choice, inline under the header. |
| + RenameBanner(store: store, thread: thread) |
| + } |
| + } |
| + .sheet(item: $headerProfile) { target in |
| + MemberProfileSheet(store: store, uid: target.id) { e in |
| + Avatar(name: store.displayName(for: e), size: 72, |
| + source: store.avatarSource(for: e)) |
| + } |
| + .presentationDetents([.medium]) |
| + } |
| .safeAreaInset(edge: .bottom) { |
| // An app surface owns the whole pane, inputs included — the |
| // chat composer steps aside (ChatView's interface-view rule). |
| @@ -1365,7 +1393,8 @@ struct NativeChatScreen: View { |
| // the web's commandCatalog(threadId). |
| let items = typeaheadItems(token, roster: store.roster, |
| myUserId: store.myUserId, hasBot: hasBot, |
| - appCommands: AppDefaultCommand.localCommands(appID: thread.appId)) |
| + appCommands: AppDefaultCommand.localCommands(appID: thread.appId), |
| + displayNames: store.displayLabelMap()) |
| nativeTypeahead = items.isEmpty ? nil : (token, items) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift| index 4a8942f..009bcf5 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift |
| @@ -85,7 +85,8 @@ public enum TypeaheadItem: Equatable, Identifiable { |
| /// layer. |
| public func typeaheadItems(_ token: ComposerToken, roster: [RosterEntry], |
| myUserId: String, hasBot: Bool, |
| - appCommands: [(name: String, desc: String)] = []) -> [TypeaheadItem] { |
| + appCommands: [(name: String, desc: String)] = [], |
| + displayNames: [String: String] = [:]) -> [TypeaheadItem] { |
| switch token.kind { |
| case .command: |
| let q = token.query.lowercased() |
| @@ -122,9 +123,12 @@ public func typeaheadItems(_ token: ComposerToken, roster: [RosterEntry], |
| let q = token.query.lowercased() |
| return roster |
| // NB: JS `includes("")` is true but Swift `contains("")` is false — |
| - // an empty query must list everyone, like the web. |
| + // an empty query must list everyone, like the web. Typing the name |
| + // YOU call someone finds them too (#577, the web's rosterMatches); |
| + // what inserts stays the published @name every member can resolve. |
| .filter { $0.user_id != myUserId && !$0.label.isEmpty |
| - && (q.isEmpty || $0.label.lowercased().contains(q)) } |
| + && (q.isEmpty || $0.label.lowercased().contains(q) |
| + || (displayNames[$0.user_id]?.lowercased().contains(q) ?? false)) } |
| .prefix(8) |
| .map { .mention(name: $0.label, userId: $0.user_id) } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/Crypto.swift b/ios/LChatCore/Sources/LChatCore/Crypto.swift| index 313ef64..f5990e9 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Crypto.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Crypto.swift |
| @@ -60,7 +60,17 @@ public enum ECIES { |
| /// byte-compatible with web/static/crypto.js `wrapThreadKey`. Used when starting |
| /// a chat: the new thread key is wrapped to each member (including yourself). |
| public static func wrap(rawKey: Data, recipientPubB64: String) throws -> String { |
| - guard rawKey.count == 32, let recipientRaw = Data(base64urlEncoded: recipientPubB64) else { |
| + guard rawKey.count == 32 else { throw CryptoError.badWrap } |
| + return try seal(bytes: rawKey, recipientPubB64: recipientPubB64) |
| + } |
| + |
| + /// Seals arbitrary bytes to a recipient's public key — the twin of the |
| + /// web's `sealForPub` (web/static/crypto.js): the exact wire shape of |
| + /// `wrap`, without the 32-byte payload rule. Used for the per-viewer |
| + /// nicknames blob (#577), sealed to one's OWN public key so only the |
| + /// member's devices can open it. |
| + public static func seal(bytes: Data, recipientPubB64: String) throws -> String { |
| + guard let recipientRaw = Data(base64urlEncoded: recipientPubB64) else { |
| throw CryptoError.badWrap |
| } |
| do { |
| @@ -69,9 +79,9 @@ public enum ECIES { |
| let shared = try eph.sharedSecretFromKeyAgreement(with: recipient) |
| let aes = shared.hkdfDerivedSymmetricKey( |
| using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32) |
| - let box = try AES.GCM.seal(rawKey, using: aes) |
| + let box = try AES.GCM.seal(bytes, using: aes) |
| guard let combined = box.combined else { throw CryptoError.sealFailed } // iv||ct||tag |
| - var out = Data(eph.publicKey.x963Representation) // 65-byte uncompressed point |
| + var out = Data(eph.publicKey.x963Representation) |
| out.append(combined) |
| return out.base64urlEncodedString() |
| } catch { |
| @@ -79,6 +89,17 @@ public enum ECIES { |
| } |
| } |
| + /// Opens a `seal`/`sealForPub` blob with the PKCS#8 private key string, |
| + /// returning the plaintext bytes — `unwrap` without the thread-key length |
| + /// rule (the web twin is `openSealed`). |
| + public static func open(sealedB64: String, privPkcs8B64: String) throws -> Data { |
| + guard let pkcs8 = Data(base64urlEncoded: privPkcs8B64), |
| + let priv = try? P256.KeyAgreement.PrivateKey(derRepresentation: pkcs8) else { |
| + throw CryptoError.badWrap |
| + } |
| + return try unwrapBytes(wrappedB64: sealedB64, priv: priv) |
| + } |
| + |
| /// Arbitrary-length variant of `unwrap`, taking a live private key: the |
| /// browser sign-in handoff wraps a JSON session (home's `wrapToPublicKey`, |
| /// same layout + info string), not a 32-byte thread key. |
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index e047c94..7c4b333 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -378,6 +378,46 @@ public struct LChatAPI { |
| await postJSON("api/label", ["token": token, "label_blob": labelBlob]) |
| } |
| + /// The caller's sealed per-viewer names blob (#577) and its version ("", |
| + /// 0 when never set). nil when the fetch failed — distinct from empty, so |
| + /// a network blip can never be mistaken for "no nicknames" and overwritten. |
| + /// Hand-rolled rather than getJSON: the status must be checked, or a JSON |
| + /// error body ({"error": …} on 403/500) decodes as an all-nil Resp and |
| + /// masquerades as "never set" — the exact wipe this contract exists to stop. |
| + public func nicknames() async -> (blob: String, version: Int64)? { |
| + guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/nicknames"), |
| + resolvingAgainstBaseURL: false) else { return nil } |
| + comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| + guard let url = comps.url, |
| + let (data, resp) = try? await session.data(from: url), |
| + (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil } |
| + struct Resp: Codable { let nicknames_blob: String?; let version: Int64? } |
| + guard let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil } |
| + return (r.nicknames_blob ?? "", r.version ?? 0) |
| + } |
| + |
| + public enum NicknamesPost { case ok(Int64), conflict, failed } |
| + |
| + /// Store the sealed nicknames blob with compare-and-swap: `version` is |
| + /// what the caller last read. .conflict means another device wrote in |
| + /// between — re-read, replay the pending ops, retry. |
| + public func postNicknames(blob: String, version: Int64) async -> NicknamesPost { |
| + guard let url = URL(string: "api/nicknames", relativeTo: baseURL), |
| + let data = try? JSONSerialization.data(withJSONObject: |
| + ["token": token, "nicknames_blob": blob, "version": version]) else { return .failed } |
| + var req = URLRequest(url: url) |
| + req.httpMethod = "POST" |
| + req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| + req.httpBody = data |
| + guard let (body, resp) = try? await session.data(for: req), |
| + let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed } |
| + struct Resp: Codable { let version: Int64? } |
| + let v = (try? JSONDecoder().decode(Resp.self, from: body))?.version |
| + if code == 200 { return .ok(v ?? version + 1) } |
| + if code == 409 { return .conflict } |
| + return .failed |
| + } |
| + |
| /// The caller's pinned Away flag (GET /api/me) — what the settings toggle |
| /// shows. nil when the fetch failed. |
| public func away() async -> Bool? { |
diff --git a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift| new file mode 100644 |
| index 0000000..3603916 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift |
| @@ -0,0 +1,123 @@ |
| +import SwiftUI |
| + |
| +// A member's profile card — the native twin of the web's viewProfile |
| +// (shell.js): photo, the name I show for them, their published name when I |
| +// call them something else, the keep/adopt choice after they rename, and |
| +// "Call them something else" (#577). Shared by iOS and Mac; each app presents |
| +// it as a sheet/popover from a member row or a DM header. |
| +public struct MemberProfileSheet<AvatarView: View>: View { |
| + @ObservedObject var store: ThreadStore |
| + let uid: String |
| + // The avatar slot: each app supplies its own avatar view (they live at the |
| + // app layer, not in LChatCore). |
| + private let avatar: (RosterEntry) -> AvatarView |
| + @Environment(\.dismiss) private var dismiss |
| + |
| + @State private var editing = false |
| + @State private var draft = "" |
| + @State private var syncing = false |
| + @FocusState private var draftFocused: Bool |
| + |
| + public init(store: ThreadStore, uid: String, |
| + @ViewBuilder avatar: @escaping (RosterEntry) -> AvatarView) { |
| + self.store = store |
| + self.uid = uid |
| + self.avatar = avatar |
| + } |
| + |
| + private var entry: RosterEntry? { store.roster.first { $0.user_id == uid } } |
| + private var shownName: String { store.displayName(for: uid) } |
| + private var publishedName: String { entry?.label ?? "" } |
| + private var isPerson: Bool { !(entry?.isAgent ?? false) && uid != store.myUserId } |
| + |
| + public var body: some View { |
| + VStack(spacing: 10) { |
| + if let entry { |
| + avatar(entry) |
| + } |
| + HStack(spacing: 6) { |
| + Text(shownName.isEmpty ? "Someone" : shownName) |
| + .font(.title2.weight(.semibold)) |
| + if let entry, entry.isAgent { AgentBadge(entry.isAI ? "AI" : "BOT") } |
| + } |
| + |
| + if isPerson { |
| + if editing { |
| + editor |
| + } else { |
| + nickArea |
| + } |
| + } |
| + } |
| + .padding(24) |
| + .frame(minWidth: 280) |
| + } |
| + |
| + // The per-viewer names block: the pending keep/adopt choice, the |
| + // "Goes by" line, and the rename entry point. |
| + @ViewBuilder private var nickArea: some View { |
| + if let notice = store.renameNotice(for: uid) { |
| + VStack(spacing: 8) { |
| + (Text(notice.from).bold() + Text(" now goes by ") + Text(notice.to).bold()) |
| + .font(.subheadline) |
| + HStack(spacing: 10) { |
| + Button("Use \(notice.to)") { resolve(keep: false) } |
| + Button("Keep \(notice.from)") { resolve(keep: true) } |
| + } |
| + .buttonStyle(.bordered) |
| + } |
| + } else if let nick = store.nicknames[uid], !nick.name.isEmpty, |
| + !publishedName.isEmpty, publishedName != nick.name { |
| + Text("Goes by \(publishedName)") |
| + .font(.subheadline) |
| + .foregroundStyle(.secondary) |
| + } |
| + |
| + Button(store.nicknames[uid]?.name.isEmpty == false |
| + ? "Change what you call them" : "Call them something else") { |
| + draft = shownName |
| + editing = true |
| + draftFocused = true |
| + } |
| + .buttonStyle(.borderless) |
| + |
| + if syncing { |
| + Text("Couldn't reach the server just now — saved here, still syncing.") |
| + .font(.footnote) |
| + .foregroundStyle(.secondary) |
| + } |
| + } |
| + |
| + @ViewBuilder private var editor: some View { |
| + TextField(publishedName, text: $draft) |
| + .textFieldStyle(.roundedBorder) |
| + .multilineTextAlignment(.center) |
| + .frame(maxWidth: 240) |
| + .focused($draftFocused) |
| + .accessibilityLabel("Your name for them") |
| + .onSubmit { save() } |
| + HStack(spacing: 10) { |
| + Button("Save") { save() } |
| + .buttonStyle(.borderedProminent) |
| + Button("Cancel") { editing = false } |
| + .buttonStyle(.bordered) |
| + } |
| + } |
| + |
| + private func save() { |
| + let v = draft.trimmingCharacters(in: .whitespacesAndNewlines) |
| + editing = false |
| + Task { |
| + // Typing their published name back = no override to keep. |
| + let ok = await store.setNickname(uid: uid, name: v == publishedName ? "" : v) |
| + syncing = !ok |
| + } |
| + } |
| + |
| + private func resolve(keep: Bool) { |
| + Task { |
| + let ok = await store.resolveRename(uid: uid, keep: keep) |
| + syncing = !ok |
| + } |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift| index b422cfe..356cbc3 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| @@ -27,6 +27,18 @@ public final class NativeFeedModelCache { |
| // roster without a model rebuild. |
| private var senderNames: [String: [String: String]] = [:] |
| + /// The latest resolved names for an instance (uid → the name this member |
| + /// shows, per-viewer names included) — the webview embed seed reads this, |
| + /// so the page can display my names without ever holding a key. |
| + public func names(instance instanceID: String) -> [String: String] { |
| + senderNames[instanceID] ?? [:] |
| + } |
| + |
| + /// Fired on every fresh fold's names, after the models update — the app |
| + /// layer pushes the map into its cached webviews (FeedWebCache), whose |
| + /// baked boot seeds otherwise go stale when someone renames. |
| + public var onNamesUpdate: ((String, [String: String]) -> Void)? |
| + |
| /// A fresh roster fold's names: remember them for models created later, |
| /// and update the live/parked ones now. |
| public func updateSenderNames(instance instanceID: String, labels: [String: String]) { |
| @@ -34,6 +46,7 @@ public final class NativeFeedModelCache { |
| for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| if entry.model.senderNames != labels { entry.model.senderNames = labels } |
| } |
| + onNamesUpdate?(instanceID, labels) |
| } |
| /// The live model for a thread — reused (and re-marked recent) when parked, |
diff --git a/ios/LChatCore/Sources/LChatCore/Nicknames.swift b/ios/LChatCore/Sources/LChatCore/Nicknames.swift| new file mode 100644 |
| index 0000000..60705c4 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/Nicknames.swift |
| @@ -0,0 +1,59 @@ |
| +import Foundation |
| + |
| +// Per-viewer names (#577): what *I* call people, independent of the name they |
| +// publish. The map is one blob on the server, sealed to the member's OWN |
| +// public key (ECIES) — other members can't open it; the member's devices, |
| +// which share the keypair, can. The two RULES here are pure twins of |
| +// web/static/ui.js displayNameFor / renameNotice — the single source both |
| +// platforms mirror; drift is a bug (NicknamesTests pins them). |
| + |
| +public struct NicknameEntry: Codable, Equatable, Sendable { |
| + /// What the viewer calls them. |
| + public var name: String |
| + /// The published label the viewer last acknowledged — rename detection |
| + /// compares against it, so each rename asks exactly once. |
| + public var seen: String |
| + |
| + public init(name: String, seen: String) { |
| + self.name = name |
| + self.seen = seen |
| + } |
| +} |
| + |
| +public enum Nicknames { |
| + /// The name to show: the viewer's own name for someone wins over the name |
| + /// they publish. "" when neither exists (callers pick their own fallback). |
| + public static func displayName(_ nick: NicknameEntry?, label: String?) -> String { |
| + if let nick, !nick.name.isEmpty { return nick.name } |
| + return label ?? "" |
| + } |
| + |
| + /// When the published name has moved on from what the viewer last |
| + /// acknowledged, they get a choice (keep my name / use theirs) — nil when |
| + /// there's nothing to ask: no nickname, no published name yet, the change |
| + /// is already acknowledged, or they renamed to exactly what I call them. |
| + public static func renameNotice(_ nick: NicknameEntry?, label: String?) -> (from: String, to: String)? { |
| + guard let nick, !nick.name.isEmpty, let label, !label.isEmpty else { return nil } |
| + if label == nick.seen || label == nick.name { return nil } |
| + return (from: nick.name, to: label) |
| + } |
| + |
| + // The blob's JSON shape — { v: 1, names: { uid: { name, seen } } }, |
| + // exactly what the web seals (shell.js saveNicknamesNow). |
| + private struct Blob: Codable { |
| + var v: Int |
| + var names: [String: NicknameEntry] |
| + } |
| + |
| + /// Opens a sealed nicknames blob with the member's PKCS#8 private key. |
| + public static func open(blobB64: String, privPkcs8B64: String) throws -> [String: NicknameEntry] { |
| + let plain = try ECIES.open(sealedB64: blobB64, privPkcs8B64: privPkcs8B64) |
| + return (try JSONDecoder().decode(Blob.self, from: plain)).names |
| + } |
| + |
| + /// Seals a nicknames map to the member's own public key. |
| + public static func seal(_ names: [String: NicknameEntry], recipientPubB64: String) throws -> String { |
| + let plain = try JSONEncoder().encode(Blob(v: 1, names: names)) |
| + return try ECIES.seal(bytes: plain, recipientPubB64: recipientPubB64) |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/RenameBanner.swift b/ios/LChatCore/Sources/LChatCore/RenameBanner.swift| new file mode 100644 |
| index 0000000..07103fe |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/RenameBanner.swift |
| @@ -0,0 +1,45 @@ |
| +import SwiftUI |
| + |
| +// A DM partner renamed themself while I hold my own name for them (#577): a |
| +// slim inline row between the header and the feed — occupying layout space, |
| +// never floating — offering the choice once. Renders nothing when there's |
| +// nothing to ask. The native twin of the web's #rename-banner (shell.js); |
| +// the same choice also lives on the member's profile card. |
| +public struct RenameBanner: View { |
| + @ObservedObject var store: ThreadStore |
| + let thread: ChatThread |
| + |
| + public init(store: ThreadStore, thread: ChatThread) { |
| + self.store = store |
| + self.thread = thread |
| + } |
| + |
| + private var other: String? { |
| + guard thread.kind == "dm" else { return nil } |
| + return thread.members.first { $0 != store.myUserId } |
| + } |
| + |
| + public var body: some View { |
| + if let other, let notice = store.renameNotice(for: other) { |
| + HStack(spacing: 10) { |
| + (Text(notice.from).bold() + Text(" now goes by ") + Text(notice.to).bold()) |
| + .font(.subheadline) |
| + .lineLimit(1) |
| + Button("Use \(notice.to)") { |
| + Task { await store.resolveRename(uid: other, keep: false) } |
| + } |
| + Button("Keep \(notice.from)") { |
| + Task { await store.resolveRename(uid: other, keep: true) } |
| + } |
| + } |
| + .buttonStyle(.bordered) |
| + .controlSize(.small) |
| + .padding(.horizontal, 12) |
| + .padding(.vertical, 6) |
| + .frame(maxWidth: .infinity) |
| + .background(.bar) |
| + .overlay(alignment: .bottom) { Divider() } |
| + .accessibilityElement(children: .contain) |
| + } |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift b/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift| index 151a5d2..7b575a2 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift |
| @@ -23,6 +23,11 @@ public enum SharedThreadKeys { |
| // message's author. Optional so entries parked by older builds still |
| // decode (they fall back to the quiet non-author path). |
| public var myUid: String? |
| + // This thread's members, uid → the name THIS member shows for them |
| + // (per-viewer names resolved, #577) — the NSE names senders with it |
| + // instead of the payload's snapshot. Optional: older entries decode |
| + // and fall back to the snapshot. |
| + public var names: [String: String]? |
| } |
| private static let service = "com.lchat.client.threadkeys" |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 650ce93..7817046 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -22,6 +22,17 @@ public final class ThreadStore: ObservableObject { |
| private var tiers: [TierRow] = [] // /api/tiers rows, personalised |
| private var rosterLabels: [String: String] = [:] |
| + // Per-viewer names (#577): what *I* call people, opened from the sealed |
| + // blob only this member's devices can read. displayLabels is the resolved |
| + // view (my name for them → their published name) display surfaces read. |
| + @Published public private(set) var nicknames: [String: NicknameEntry] = [:] |
| + private var displayLabels: [String: String] = [:] |
| + private var nicknamesVersion: Int64 = 0 |
| + // Ops this device has made that the server copy may not have yet |
| + // (uid → entry, nil = cleared): saves re-read the server blob and replay |
| + // these on top — the web client's journal (shell.js), mirrored. |
| + private var nicknameOps: [String: NicknameEntry?] = [:] |
| + |
| private var api: LChatAPI? { |
| guard let base = instance.baseURL else { return nil } |
| return LChatAPI(baseURL: base, token: instance.deviceToken) |
| @@ -74,6 +85,10 @@ public final class ThreadStore: ObservableObject { |
| await api.ensurePubkey(instance.userPub) |
| + // Independent of the fetches below — runs concurrently, off the hot |
| + // path (refresh refires on every socket frame). |
| + async let nickFetchAsync = api.nicknames() |
| + |
| let people = await api.roster() |
| if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) { |
| myUserId = me.user_id |
| @@ -82,6 +97,8 @@ public final class ThreadStore: ObservableObject { |
| instanceName = await api.groupName() |
| let serverThreads = await api.threads() |
| let tierRows = await api.tiers() |
| + // nil = fetch failed (keep what we hold); "" = genuinely never set. |
| + let nickFetch = await nickFetchAsync |
| // ALL crypto in one detached pass — the instance-key unwrap, roster |
| // labels, per-thread ECDH unwraps, previews, titles, app ids. refresh() |
| @@ -90,10 +107,17 @@ public final class ThreadStore: ObservableObject { |
| // milliseconds-scale P-256 agreement; each preview/title an AES open). |
| let inst = instance |
| let uid = myUserId |
| + // Only adopt a server nicknames copy at least as new as what we hold — |
| + // a slow refresh that read BEFORE our save landed must not stomp it. |
| + let nickBlob = (nickFetch != nil && nickFetch!.version >= nicknamesVersion) |
| + ? nickFetch?.blob : nil |
| + let heldNicknames = nicknames |
| + let pendingOps = nicknameOps |
| let folded = await Task.detached(priority: .userInitiated) { |
| Self.fold(instance: inst, myUserId: uid, peopleIn: people, |
| wrappedInstanceKey: wrappedInstanceKey, serverThreads: serverThreads, |
| - tierRows: tierRows) |
| + nicknamesBlob: nickBlob, heldNicknames: heldNicknames, |
| + pendingOps: pendingOps, tierRows: tierRows) |
| }.value |
| instanceKeyB64 = folded.instanceKeyB64 |
| tierKeysB64 = folded.tierKeysB64 |
| @@ -107,10 +131,16 @@ public final class ThreadStore: ObservableObject { |
| } |
| roster = folded.people |
| rosterLabels = folded.rosterLabels |
| + nicknames = folded.nicknames |
| + if let nf = nickFetch, nf.version >= nicknamesVersion { nicknamesVersion = nf.version } |
| + displayLabels = folded.displayLabels |
| + // A save that failed earlier left ops queued — retry off this refresh |
| + // (the web twin's queueNickRetry; without it a rename could be lost). |
| + if !nicknameOps.isEmpty { queueNickRetry(delay: 0) } |
| // Hand the fresh names to the native feed models (live and parked) — |
| - // bubble headers resolve senders by uid against the current roster. |
| + // bubble headers resolve senders by uid, my name for them winning. |
| NativeFeedModelCache.shared.updateSenderNames(instance: instance.id, |
| - labels: folded.rosterLabels) |
| + labels: folded.displayLabels) |
| // Park every member's decrypted profile photo (downscaled) in the |
| // shared cache — the way the fold parks thread keys in |
| // SharedThreadKeys — so the notification extension can show a sender's |
| @@ -148,6 +178,10 @@ public final class ThreadStore: ObservableObject { |
| var tierKeysB64: [String: String] // level id -> unwrapped key (ours) |
| var people: [RosterEntry] |
| var rosterLabels: [String: String] |
| + // Per-viewer names: the opened map, and labels with my names resolved |
| + // in (what titles, sender headers, and member lists display). |
| + var nicknames: [String: NicknameEntry] |
| + var displayLabels: [String: String] |
| var rows: [ChatThread] |
| // Raw server read watermarks by thread — merged into serverReadMarks |
| // on the main actor (the fold runs detached and can't touch it). |
| @@ -158,6 +192,9 @@ public final class ThreadStore: ObservableObject { |
| peopleIn: [RosterEntry], |
| wrappedInstanceKey: String?, |
| serverThreads: [ServerThread], |
| + nicknamesBlob: String?, |
| + heldNicknames: [String: NicknameEntry], |
| + pendingOps: [String: NicknameEntry?], |
| tierRows: [TierRow] = []) -> Folded { |
| var people = peopleIn |
| var instanceKeyB64: String? |
| @@ -193,6 +230,26 @@ public final class ThreadStore: ObservableObject { |
| } |
| let rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) }, |
| uniquingKeysWith: { a, _ in a }) |
| + // Per-viewer names (#577): open my sealed overrides (a fetch failure |
| + // keeps what this store already held — never "forget" names on a |
| + // blip), then resolve every label through them for display surfaces. |
| + var nicknames = heldNicknames |
| + if let blob = nicknamesBlob { |
| + if blob.isEmpty { |
| + nicknames = [:] |
| + } else if let opened = try? Nicknames.open(blobB64: blob, privPkcs8B64: instance.userPriv) { |
| + nicknames = opened |
| + } |
| + } |
| + // Replay this device's not-yet-landed ops on top of the server copy — |
| + // a rename whose save failed must not visibly revert (it stays applied |
| + // here and the queued save retries until it lands). |
| + for (uid, entry) in pendingOps { |
| + if let entry { nicknames[uid] = entry } else { nicknames.removeValue(forKey: uid) } |
| + } |
| + let displayLabels = rosterLabels.reduce(into: [String: String]()) { out, kv in |
| + out[kv.key] = Nicknames.displayName(nicknames[kv.key], label: kv.value) |
| + } |
| // Member profile-photo blob ids, for DM/self avatars (decrypted under the |
| // instance key, like the labels above). Groups use their own thread key. |
| let rosterAvatars = Dictionary( |
| @@ -261,7 +318,8 @@ public final class ThreadStore: ObservableObject { |
| if let app { title = appTitles[app] ?? appPayload?.title ?? app.capitalized } |
| else { title = "Notes" } |
| case "dm": |
| - title = rosterLabels[other] ?? "Someone" |
| + // displayLabels: the DM titles by MY name for the person (#577). |
| + title = (displayLabels[other]?.isEmpty == false) ? displayLabels[other]! : "Someone" |
| default: |
| if let n = groupName, !n.isEmpty { |
| title = n |
| @@ -269,7 +327,7 @@ public final class ThreadStore: ObservableObject { |
| // Unnamed group: title from the other members' names — mirrors |
| // autoGroupTitle in web/static/format.js exactly. |
| let names = members.filter { $0 != myUserId } |
| - .compactMap { rosterLabels[$0] }.filter { !$0.isEmpty } |
| + .compactMap { displayLabels[$0] }.filter { !$0.isEmpty } |
| title = autoGroupTitle(names) |
| } |
| } |
| @@ -340,10 +398,17 @@ public final class ThreadStore: ObservableObject { |
| ReadState.setLastSeen(instance: instance.id, thread: t.id, at: sm) |
| } |
| // Park the key + decrypted title for the notification extension: a |
| - // push's ciphertext is decrypted on-device against this store. |
| + // push's ciphertext is decrypted on-device against this store — |
| + // plus the NICKNAMED members' names, so banners speak MY names for |
| + // people (#577). Only overridden names park: for everyone else the |
| + // payload's own snapshot is fresher than a fold-time roster label. |
| + let memberNames = Dictionary(uniqueKeysWithValues: |
| + members.compactMap { m in |
| + nicknames[m] != nil ? displayLabels[m].map { (m, $0) } : nil |
| + }) |
| SharedThreadKeys.save(threadID: t.id, entry: .init( |
| key: raw, title: title, origin: instance.origin, group: t.kind == "group", |
| - myUid: myUserId)) |
| + myUid: myUserId, names: memberNames.isEmpty ? nil : memberNames)) |
| rows.append(ChatThread( |
| id: t.id, |
| kind: t.kind, |
| @@ -377,7 +442,144 @@ public final class ThreadStore: ObservableObject { |
| // the default view but reveals them in search, so they can be reopened. |
| rows.sort(by: order) |
| return Folded(instanceKeyB64: instanceKeyB64, tierKeysB64: tierKeysB64, people: people, |
| - rosterLabels: rosterLabels, rows: rows, readMarks: readMarks) |
| + rosterLabels: rosterLabels, nicknames: nicknames, |
| + displayLabels: displayLabels, rows: rows, readMarks: readMarks) |
| + } |
| + |
| + // MARK: - Per-viewer names (#577) |
| + |
| + /// The resolved uid → display-name map (my names winning), for the |
| + /// webview embed bridge: the page holds no key that could open the |
| + /// nicknames blob, so the shell hands it names, never key material. |
| + public func displayLabelMap() -> [String: String] { displayLabels } |
| + |
| + /// The name to show for a member — my name for them, their published |
| + /// name, then the caller's fallback. The LIVE maps resolve first: between |
| + /// a setNickname and its refresh the fold-derived cache is one name stale. |
| + public func displayName(for uid: String) -> String { |
| + let live = Nicknames.displayName(nicknames[uid], label: rosterLabels[uid]) |
| + if !live.isEmpty { return live } |
| + return displayLabels[uid] ?? "" |
| + } |
| + |
| + public func displayName(for entry: RosterEntry) -> String { |
| + let name = Nicknames.displayName(nicknames[entry.user_id], label: entry.label) |
| + return name.isEmpty ? entry.label : name |
| + } |
| + |
| + /// The unacknowledged rename for a member, if any (see Nicknames.renameNotice). |
| + public func renameNotice(for uid: String) -> (from: String, to: String)? { |
| + Nicknames.renameNotice(nicknames[uid], label: rosterLabels[uid]) |
| + } |
| + |
| + /// Set (or with "" clear) my name for someone, stamping `seen` with their |
| + /// current published label so a later rename of theirs prompts me exactly |
| + /// once. Returns whether the save landed (false = applied locally, op |
| + /// queued, retried by queueNickRetry / the next refresh — never lost |
| + /// silently). Mirrors setNickname in web/static/shell.js. |
| + @discardableResult |
| + public func setNickname(uid: String, name: String) async -> Bool { |
| + let entry = name.isEmpty ? nil : NicknameEntry(name: name, seen: rosterLabels[uid] ?? "") |
| + if let entry { nicknames[uid] = entry } else { nicknames.removeValue(forKey: uid) } |
| + nicknameOps[uid] = entry |
| + let ok = await saveNicknames() |
| + await refresh() // titles/senders/previews re-fold with the new name |
| + return ok |
| + } |
| + |
| + /// Acknowledge a rename: keep my name but stop prompting (keep=true), or |
| + /// drop my name and follow theirs from now on (keep=false). |
| + @discardableResult |
| + public func resolveRename(uid: String, keep: Bool) async -> Bool { |
| + guard let nick = nicknames[uid] else { return true } |
| + let entry = keep ? NicknameEntry(name: nick.name, seen: rosterLabels[uid] ?? nick.seen) : nil |
| + if let entry { nicknames[uid] = entry } else { nicknames.removeValue(forKey: uid) } |
| + nicknameOps[uid] = entry |
| + let ok = await saveNicknames() |
| + await refresh() |
| + return ok |
| + } |
| + |
| + // Saves are serialized on a task chain — two quick renames must not |
| + // interleave their read/POST pairs (the web's nickSaveChain, mirrored). |
| + private var nickSaveChain: Task<Bool, Never>? |
| + |
| + private func saveNicknames() async -> Bool { |
| + let prev = nickSaveChain |
| + let task = Task { [weak self] () -> Bool in |
| + _ = await prev?.value |
| + guard let self else { return false } |
| + return await self.saveNicknamesNow() |
| + } |
| + nickSaveChain = task |
| + return await task.value |
| + } |
| + |
| + /// Read-modify-write with compare-and-swap: re-read the server blob, |
| + /// replay this device's pending ops on top, seal, store. A version |
| + /// conflict (another device wrote in between) re-reads and retries; on |
| + /// failure the ops stay queued and queueNickRetry keeps trying (and every |
| + /// refresh re-drives it), so a rename is never lost silently. Mirrors |
| + /// saveNicknamesNow in web/static/shell.js. |
| + private func saveNicknamesNow() async -> Bool { |
| + guard let api, !nicknameOps.isEmpty else { return nicknameOps.isEmpty } |
| + let priv = instance.userPriv |
| + let pub = instance.userPub |
| + for _ in 0..<3 { |
| + guard let (blob, version) = await api.nicknames() else { break } |
| + let included = nicknameOps |
| + // The ECIES open + seal are milliseconds-scale P-256 work — off |
| + // the main actor, like the fold's crypto. |
| + let sealed = await Task.detached(priority: .userInitiated) { () -> String? in |
| + var merged: [String: NicknameEntry] = [:] |
| + if !blob.isEmpty { |
| + guard let opened = try? Nicknames.open(blobB64: blob, privPkcs8B64: priv) else { |
| + return nil // never overwrite a blob we can't read |
| + } |
| + merged = opened |
| + } |
| + for (uid, entry) in included { |
| + if let entry { merged[uid] = entry } else { merged.removeValue(forKey: uid) } |
| + } |
| + return try? Nicknames.seal(merged, recipientPubB64: pub) |
| + }.value |
| + guard let sealed else { break } |
| + switch await api.postNicknames(blob: sealed, version: version) { |
| + case .ok(let v): |
| + nicknamesVersion = v |
| + for (uid, entry) in included { |
| + if let entry { nicknames[uid] = entry } else { nicknames.removeValue(forKey: uid) } |
| + } |
| + // Clear only what this save wrote — an op re-set mid-save stays. |
| + for (uid, entry) in included where nicknameOps[uid] == entry { |
| + nicknameOps.removeValue(forKey: uid) |
| + } |
| + return true |
| + case .conflict: |
| + continue // fresh read next loop; the replay converges |
| + case .failed: |
| + break |
| + } |
| + break |
| + } |
| + queueNickRetry() |
| + return false |
| + } |
| + |
| + // A failed save must not be a silent loss: keep trying while ops are |
| + // queued (the web's queueNickRetry). One retry in flight at a time. |
| + private var nickRetryScheduled = false |
| + private func queueNickRetry(delay: UInt64 = 5) { |
| + guard !nickRetryScheduled, !nicknameOps.isEmpty else { return } |
| + nickRetryScheduled = true |
| + Task { [weak self] in |
| + try? await Task.sleep(nanoseconds: delay * 1_000_000_000) |
| + guard let self else { return } |
| + self.nickRetryScheduled = false |
| + if !self.nicknameOps.isEmpty, await self.saveNicknames() { |
| + await self.refresh() |
| + } |
| + } |
| } |
| // Pinned chats first, then most-recent activity — mirrors the web list. |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/Fixtures/nicknames-vector.json b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/nicknames-vector.json| new file mode 100644 |
| index 0000000..d6aa732 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/nicknames-vector.json |
| @@ -0,0 +1,20 @@ |
| +{ |
| + "_comment": "Per-viewer names blob test vector (#577). Generated by test/nicknames.test.mjs from web/static/crypto.js sealForPub. LChatCore's NicknamesVectorTests opens sealedB64 with privPkcs8B64 and must get expectedNames — and seals its own blob to pubRawB64, which must open on both sides. base64url throughout. Regenerate with: node test/nicknames.test.mjs --emit", |
| + "privPkcs8B64": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgQiXtS_yQPopgQPUfkImUuKrfBbfxlcMyqOT7BfoAz6OhRANCAAQbAb59BcyPSP_e1Ia1n23nF9daFhDk2p0hsGCe6xiBR_wo08QdhmxZ_rkGQHfn4R7SDdn0rei4va-mBqUJVawq", |
| + "pubRawB64": "BBsBvn0FzI9I_97UhrWfbecX11oWEOTanSGwYJ7rGIFH_CjTxB2GbFn-uQZAd-fhHtIN2fSt6Li9r6YGpQlVrCo", |
| + "sealedB64": "BEfCsJoZGi2afXV1l1u8WNCUnq-kNyR37iM1xN56C4Xnvfq4dagGEq3Ed0NWyt-j4CbRCARSX6K0ssKB_vJptSm2Fh-PytANuJNNiUIajiS-tA2sS6e7ui5ebpk-K_i0DUtSEllsObCEClU0YfZtkTNXH0YIhQQXTbLvLt8Ux-sucDLTiSOrZ0u3Z2BkLkSJTzmYFKSVbzQkaAgky09Wxva7_cOXRv_pbIs-mzpXqAnOp00w9biGsLST-YNnfeCG3EtDTDr_LYWxJiaBWPpttzCPcdkn6tcxvT3nJKmBopcj3q-6ue3KaaIgXuPN8IkdbteonUKVFg", |
| + "expectedNames": { |
| + "uid-dermot": { |
| + "name": "Dermo", |
| + "seen": "Dermot" |
| + }, |
| + "uid-anna": { |
| + "name": "Mum", |
| + "seen": "Anna Lee" |
| + }, |
| + "uid-jose": { |
| + "name": "José 🎉", |
| + "seen": "Jose" |
| + } |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/NicknamesTests.swift b/ios/LChatCore/Tests/LChatCoreTests/NicknamesTests.swift| new file mode 100644 |
| index 0000000..d6b2d1f |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/NicknamesTests.swift |
| @@ -0,0 +1,32 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// Pure-rule twins of web/static/ui.js displayNameFor / renameNotice (#577) — |
| +// the same cases test/ui.test.mjs pins on the web side, so the two |
| +// implementations can't drift apart. |
| +final class NicknamesTests: XCTestCase { |
| + func testDisplayNameMyNameWins() { |
| + XCTAssertEqual(Nicknames.displayName(.init(name: "Dermo", seen: "Dermo"), label: "Dermot"), "Dermo") |
| + XCTAssertEqual(Nicknames.displayName(nil, label: "Dermot"), "Dermot") |
| + XCTAssertEqual(Nicknames.displayName(nil, label: nil), "") |
| + // Empty nickname = no nickname. |
| + XCTAssertEqual(Nicknames.displayName(.init(name: "", seen: ""), label: "Dermot"), "Dermot") |
| + } |
| + |
| + func testRenameNoticeFiresOncePerUnacknowledgedRename() { |
| + // The #577 scenario: invited as Dermo, renames to Dermot → the choice. |
| + let fresh = Nicknames.renameNotice(.init(name: "Dermo", seen: "Dermo"), label: "Dermot") |
| + XCTAssertEqual(fresh?.from, "Dermo") |
| + XCTAssertEqual(fresh?.to, "Dermot") |
| + // Acknowledged (kept my name): seen catches up, no more prompting. |
| + XCTAssertNil(Nicknames.renameNotice(.init(name: "Dermo", seen: "Dermot"), label: "Dermot")) |
| + // …until they rename AGAIN. |
| + let again = Nicknames.renameNotice(.init(name: "Dermo", seen: "Dermot"), label: "Diarmuid") |
| + XCTAssertEqual(again?.to, "Diarmuid") |
| + // They renamed to exactly what I call them — nothing to ask. |
| + XCTAssertNil(Nicknames.renameNotice(.init(name: "Dermo", seen: "x"), label: "Dermo")) |
| + // No nickname / no published name yet → never a notice. |
| + XCTAssertNil(Nicknames.renameNotice(nil, label: "Dermot")) |
| + XCTAssertNil(Nicknames.renameNotice(.init(name: "Dermo", seen: "Dermo"), label: "")) |
| + } |
| +} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/NicknamesVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/NicknamesVectorTests.swift| new file mode 100644 |
| index 0000000..e8a5fe2 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/NicknamesVectorTests.swift |
| @@ -0,0 +1,42 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// Cross-implementation gate for the per-viewer names blob (#577): opens the |
| +// web-sealed fixture (emitted by test/nicknames.test.mjs from crypto.js |
| +// sealForPub) and round-trips a native seal — so the CryptoKit seal/open pair |
| +// can never drift from the web's wire or JSON shape. Runs with `swift test` |
| +// (no simulator), like ECIESVectorTests. |
| +final class NicknamesVectorTests: XCTestCase { |
| + private struct Vector: Decodable { |
| + let privPkcs8B64: String |
| + let pubRawB64: String |
| + let sealedB64: String |
| + let expectedNames: [String: NicknameEntry] |
| + } |
| + |
| + private func vector() throws -> Vector { |
| + let url = try XCTUnwrap( |
| + Bundle.module.url(forResource: "Fixtures/nicknames-vector", withExtension: "json"), |
| + "fixture missing — regenerate with: node test/nicknames.test.mjs --emit") |
| + return try JSONDecoder().decode(Vector.self, from: Data(contentsOf: url)) |
| + } |
| + |
| + func testOpensWebSealedBlob() throws { |
| + let v = try vector() |
| + let names = try Nicknames.open(blobB64: v.sealedB64, privPkcs8B64: v.privPkcs8B64) |
| + XCTAssertEqual(names, v.expectedNames) |
| + } |
| + |
| + func testNativeSealRoundTrips() throws { |
| + let v = try vector() |
| + let sealed = try Nicknames.seal(v.expectedNames, recipientPubB64: v.pubRawB64) |
| + let reopened = try Nicknames.open(blobB64: sealed, privPkcs8B64: v.privPkcs8B64) |
| + XCTAssertEqual(reopened, v.expectedNames) |
| + } |
| + |
| + func testWrongKeyCannotOpen() throws { |
| + let v = try vector() |
| + let stranger = ECIES.generateUserKeypair() |
| + XCTAssertThrowsError(try Nicknames.open(blobB64: v.sealedB64, privPkcs8B64: stranger.priv)) |
| + } |
| +} |
diff --git a/ios/LChatMac/AppThreadWebView.swift b/ios/LChatMac/AppThreadWebView.swift| index 40f8b4e..4ac8155 100644 |
| --- a/ios/LChatMac/AppThreadWebView.swift |
| +++ b/ios/LChatMac/AppThreadWebView.swift |
| @@ -44,6 +44,9 @@ struct AppThreadWebView: NSViewRepresentable { |
| "threadKey": thread.threadKeyB64, |
| ] |
| if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| + // Per-viewer names (#577): names only — never key material. |
| + let names = NativeFeedModelCache.shared.names(instance: instance.id) |
| + if !names.isEmpty { seed["names"] = names } |
| // The native split renders chat itself — this page is purely the |
| // app's INTERFACE (app threads otherwise default to their chat view). |
| seed["view"] = "interface" |
diff --git a/ios/LChatMac/CommandPanelWebView.swift b/ios/LChatMac/CommandPanelWebView.swift| index 737a0eb..eb6cd4f 100644 |
| --- a/ios/LChatMac/CommandPanelWebView.swift |
| +++ b/ios/LChatMac/CommandPanelWebView.swift |
| @@ -49,6 +49,9 @@ final class CommandHost: NSObject, WKScriptMessageHandler { |
| "mode": "command", |
| ] |
| if let ik = instanceKeyB64 { seed["instanceKey"] = ik } |
| + // Per-viewer names (#577): names only — never key material. |
| + let names = NativeFeedModelCache.shared.names(instance: instance.id) |
| + if !names.isEmpty { seed["names"] = names } |
| let cfg = WKWebViewConfiguration() |
| cfg.websiteDataStore = .default() |
| if let data = try? JSONSerialization.data(withJSONObject: seed), |
diff --git a/ios/LChatMac/GroupSheets.swift b/ios/LChatMac/GroupSheets.swift| index 7dfe6c6..ae6b680 100644 |
| --- a/ios/LChatMac/GroupSheets.swift |
| +++ b/ios/LChatMac/GroupSheets.swift |
| @@ -13,6 +13,9 @@ struct MembersSheet: View { |
| @State private var selected: Set<String> = [] |
| @State private var busy = false |
| @State private var note: String? |
| + // A clicked member's profile card — rename them for yourself there (#577). |
| + @State private var profile: ProfileTarget? |
| + struct ProfileTarget: Identifiable { let id: String } |
| private var members: [RosterEntry] { |
| thread.members.compactMap { id in store.roster.first { $0.user_id == id } } |
| @@ -84,6 +87,12 @@ struct MembersSheet: View { |
| .padding(14) |
| .frame(width: 300) |
| .tint(accent) |
| + .sheet(item: $profile) { target in |
| + MemberProfileSheet(store: store, uid: target.id) { e in |
| + NativeChatView.ThreadAvatar(title: store.displayName(for: e), |
| + source: store.avatarSource(for: e), size: 72) |
| + } |
| + } |
| } |
| // The user's chosen theme accent, resolved for light/dark — the hard-coded |
| @@ -94,17 +103,22 @@ struct MembersSheet: View { |
| private func row(_ person: RosterEntry, selectable: Bool) -> some View { |
| Button { |
| - guard selectable else { return } |
| - if selected.contains(person.user_id) { |
| - selected.remove(person.user_id) |
| - } else { |
| - selected.insert(person.user_id) |
| + if selectable { |
| + if selected.contains(person.user_id) { |
| + selected.remove(person.user_id) |
| + } else { |
| + selected.insert(person.user_id) |
| + } |
| + } else if person.user_id != store.myUserId { |
| + // A member's row opens their profile card — where "call them |
| + // something else" and the rename notice live (#577). |
| + profile = ProfileTarget(id: person.user_id) |
| } |
| } label: { |
| HStack(spacing: 8) { |
| - NativeChatView.ThreadAvatar(title: person.label, |
| + NativeChatView.ThreadAvatar(title: store.displayName(for: person), |
| source: store.avatarSource(for: person), size: 26) |
| - Text(person.label).font(.system(size: 13, weight: .medium)).lineLimit(1) |
| + Text(store.displayName(for: person)).font(.system(size: 13, weight: .medium)).lineLimit(1) |
| if person.isAgent { AgentBadge(person.isAI ? "AI" : "BOT") } |
| Spacer() |
| if selectable { |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index 4a475d7..55d58cd 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -177,6 +177,12 @@ struct NativeChatView: View { |
| .sheet(item: $membersSheetThread) { t in |
| MembersSheet(store: store, thread: t) |
| } |
| + .sheet(item: $headerProfile) { target in |
| + MemberProfileSheet(store: store, uid: target.id) { e in |
| + ThreadAvatar(title: store.displayName(for: e), |
| + source: store.avatarSource(for: e), size: 72) |
| + } |
| + } |
| .sheet(item: $renameSheetThread) { t in |
| RenameGroupSheet(store: store, thread: t) |
| } |
| @@ -332,6 +338,9 @@ struct NativeChatView: View { |
| VStack(spacing: 0) { |
| feedHeader(thread) |
| Divider() |
| + // A DM partner renamed themself past my name for them |
| + // (#577): the keep/adopt choice, inline under the header. |
| + RenameBanner(store: store, thread: thread) |
| } |
| .background(FrostedBand()) |
| } |
| @@ -343,6 +352,7 @@ struct NativeChatView: View { |
| if let thread = selectedThread { |
| feedHeader(thread) |
| Divider() |
| + RenameBanner(store: store, thread: thread) |
| } |
| feed |
| } |
| @@ -539,6 +549,16 @@ struct NativeChatView: View { |
| .foregroundStyle(.secondary) |
| } |
| } |
| + // A DM's header name opens the person's profile card — where |
| + // "call them something else" and the rename notice live (#577), |
| + // matching the web header title. |
| + .contentShape(Rectangle()) |
| + .onTapGesture { |
| + guard t.kind == "dm", |
| + let other = t.members.first(where: { $0 != store.myUserId }) else { return } |
| + headerProfile = HeaderProfileTarget(id: other) |
| + } |
| + .accessibilityAddTraits(t.kind == "dm" ? .isButton : []) |
| Spacer() |
| // A companion app's view switch (web: Chat | Split | Interface) — |
| // hand-rolled segments so the View segment can be the app's own |
| @@ -577,6 +597,9 @@ struct NativeChatView: View { |
| @State private var membersSheetThread: ChatThread? |
| @State private var renameSheetThread: ChatThread? |
| + // A DM header's profile card (rename them for yourself there — #577). |
| + @State private var headerProfile: HeaderProfileTarget? |
| + struct HeaderProfileTarget: Identifiable { let id: String } |
| // One menu for every surface a thread shows on: rows, the rail, the header. |
| @ViewBuilder private func threadMenu(_ t: ChatThread) -> some View { |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 60f6c8a..a537eae 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -842,7 +842,7 @@ struct NativeFeedView: View { |
| taToken = composerToken(text) |
| taItems = taToken.map { |
| typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot, |
| - appCommands: appCommands) |
| + appCommands: appCommands, displayNames: model.senderNames) |
| } ?? [] |
| taIndex = 0 |
| // The typing beats: alive while there's a draft, an immediate |
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift| index e9e320c..a92f1bd 100644 |
| --- a/ios/LChatNSE/NotificationService.swift |
| +++ b/ios/LChatNSE/NotificationService.swift |
| @@ -56,8 +56,16 @@ final class NotificationService: UNNotificationServiceExtension { |
| // reactions to legacy uid-less messages) get a passive, silent entry — |
| // no sound, no banner, no lit screen; it only lands in the notification |
| // list. The author gets the real banner. |
| + // Senders resolve through the parked member-name map — the name THIS |
| + // member shows for them (per-viewer names, #577) — falling back to |
| + // the payload's snapshot for older entries. |
| + let senderName = { (uid: String?, fallback: String?) -> String? in |
| + if let uid, let named = entry.names?[uid], !named.isEmpty { return named } |
| + return fallback |
| + } |
| + |
| if payload.isReactionEvent { |
| - let sender = payload.name ?? "Someone" |
| + let sender = senderName(payload.uid, payload.name) ?? "Someone" |
| let emoji = payload.emoji ?? "" |
| content.title = entry.title |
| content.threadIdentifier = thread |
| @@ -78,7 +86,7 @@ final class NotificationService: UNNotificationServiceExtension { |
| return |
| } |
| - let sender = payload.name ?? "Someone" |
| + let sender = senderName(payload.uid, payload.name) ?? "Someone" |
| var text = payload.text ?? "" |
| if text.isEmpty { |
| let n = payload.attachments?.count ?? 0 |
| @@ -96,7 +104,7 @@ final class NotificationService: UNNotificationServiceExtension { |
| // anything the restyle can't handle (no entitlement, no cached photo → |
| // monogram is still applied by the system). |
| var deliver: UNNotificationContent = content |
| - if let name = payload.name, !name.isEmpty, |
| + if let name = senderName(payload.uid, payload.name), !name.isEmpty, |
| let styled = CommunicationNotification.decorate( |
| content, senderName: name, senderUID: payload.uid, |
| origin: entry.origin, conversationID: thread, |
diff --git a/package.json b/package.json| index eff7a9c..14deb72 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |
diff --git a/test/nicknames.test.mjs b/test/nicknames.test.mjs| new file mode 100644 |
| index 0000000..6d814d4 |
| --- /dev/null |
| +++ b/test/nicknames.test.mjs |
| @@ -0,0 +1,75 @@ |
| +// Cross-implementation gate for the per-viewer names blob (#577). The native |
| +// client (ios/LChatCore Nicknames.swift + Crypto.swift seal/open) reimplements |
| +// the web's sealForPub/openSealed for the {v:1, names:{uid:{name,seen}}} blob; |
| +// if either side drifts (wire shape, JSON shape), nicknames stop opening on |
| +// one platform. This pins the format two ways: |
| +// |
| +// 1. a fresh seal → open round-trips in JS (crypto.js self-consistency), and |
| +// 2. the committed fixture (LChatCore's Fixtures/nicknames-vector.json) |
| +// still opens to its expected map — the SAME fixture NicknamesVectorTests |
| +// consumes (it also seals natively and re-opens, covering both directions). |
| +// |
| +// Regenerate with: node test/nicknames.test.mjs --emit |
| +import { readFileSync, writeFileSync } from "node:fs"; |
| +import { fileURLToPath } from "node:url"; |
| +import { dirname, join } from "node:path"; |
| +import { generateUserKeypair, sealForPub, openSealed } from "../web/static/crypto.js"; |
| + |
| +const here = dirname(fileURLToPath(import.meta.url)); |
| +const fixturePath = join(here, "..", "ios", "LChatCore", "Tests", "LChatCoreTests", "Fixtures", "nicknames-vector.json"); |
| + |
| +// A stable map exercising the shapes that matter: a plain rename, a |
| +// multi-word name, and unicode. |
| +const NAMES = { |
| + "uid-dermot": { name: "Dermo", seen: "Dermot" }, |
| + "uid-anna": { name: "Mum", seen: "Anna Lee" }, |
| + "uid-jose": { name: "José 🎉", seen: "Jose" }, |
| +}; |
| + |
| +function assert(cond, msg) { |
| + if (!cond) { |
| + console.error("FAIL:", msg); |
| + process.exit(1); |
| + } |
| +} |
| + |
| +async function emit() { |
| + const kp = await generateUserKeypair(); |
| + const sealedB64 = await sealForPub(kp.publicKey, { v: 1, names: NAMES }); |
| + const opened = await openSealed(sealedB64, kp.privateKey); |
| + assert(JSON.stringify(opened.names) === JSON.stringify(NAMES), "round-trip"); |
| + const fixture = { |
| + _comment: |
| + "Per-viewer names blob test vector (#577). Generated by test/nicknames.test.mjs " + |
| + "from web/static/crypto.js sealForPub. LChatCore's NicknamesVectorTests opens " + |
| + "sealedB64 with privPkcs8B64 and must get expectedNames — and seals its own " + |
| + "blob to pubRawB64, which must open on both sides. base64url throughout. " + |
| + "Regenerate with: node test/nicknames.test.mjs --emit", |
| + privPkcs8B64: kp.privateKey, |
| + pubRawB64: kp.publicKey, |
| + sealedB64, |
| + expectedNames: NAMES, |
| + }; |
| + writeFileSync(fixturePath, JSON.stringify(fixture, null, 2) + "\n"); |
| + console.log("wrote", fixturePath); |
| +} |
| + |
| +async function test() { |
| + // (1) crypto.js self-consistency. |
| + const kp = await generateUserKeypair(); |
| + const sealed = await sealForPub(kp.publicKey, { v: 1, names: NAMES }); |
| + const opened = await openSealed(sealed, kp.privateKey); |
| + assert(JSON.stringify(opened.names) === JSON.stringify(NAMES), "fresh seal round-trips"); |
| + |
| + // (2) the committed fixture still opens — the same bytes Swift consumes. |
| + const fx = JSON.parse(readFileSync(fixturePath, "utf8")); |
| + const fromFixture = await openSealed(fx.sealedB64, fx.privPkcs8B64); |
| + assert( |
| + JSON.stringify(fromFixture.names) === JSON.stringify(fx.expectedNames), |
| + "committed fixture opens to expectedNames", |
| + ); |
| + console.log("ALL PASSED"); |
| +} |
| + |
| +if (process.argv.includes("--emit")) await emit(); |
| +else await test(); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 550bf0b..16f27a5 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -814,6 +814,20 @@ async function bootEmbed() { |
| instanceKey = await importKey(embed.instanceKey); |
| } catch {} |
| } |
| + // Per-viewer names (#577): the embed never holds userPriv, so it can't |
| + // open the nicknames blob — the native shell (which can) hands the |
| + // RESOLVED uid → name map over the bridge instead. Fold it in as |
| + // display-only overrides (seen = name: names render; the rename prompt |
| + // stays native chrome's job — updateRenameBanner bails in embeds). |
| + const applyEmbedNames = (map) => { |
| + for (const [uid, name] of Object.entries(map || {})) { |
| + if (name) nicknames[uid] = { name, seen: name }; |
| + } |
| + }; |
| + applyEmbedNames(embed.names); |
| + // The seed is baked at webview creation and parked webviews outlive a |
| + // rename — the native shell pushes fresh maps here after each fold. |
| + window.__lchatSetNames = (map) => applyEmbedNames(map); |
| // The native shell can land straight in a member app's thread too — the |
| // sandboxed proxies must be registered before the render below. |
| await loadMemberAppsNow(); |
| @@ -6695,6 +6709,10 @@ function viewProfile(uid) { |
| // nothing to ask (no nickname, acknowledged, or not a DM; the person's profile |
| // card — today opened from your DM's header — shows the same choice). |
| function updateRenameBanner(t) { |
| + // Never inside a native webview: the rename prompt is native chrome's job |
| + // there (RenameBanner.swift), and the embed can't persist a choice anyway |
| + // (no userPriv — a tap would fake-succeed and desync from native). |
| + if (embed) return; |
| const view = document.getElementById("thread-view"); |
| if (!view) return; |
| let bar = document.getElementById("rename-banner"); |