eleven / messenger Public

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>
Paul Campbell 99315ef7b77054895d6ad96316c1a041f8978c01 parent e613607
25 files changed, +883 −37
  • ios/LChat/FeedWebCache.swift +24 −0
  • ios/LChat/Views/ChatView.swift +29 −2
  • ios/LChat/Views/GroupSheets.swift +23 −7
  • ios/LChat/Views/NativeChatScreen.swift +31 −2
  • ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift +7 −3
  • ios/LChatCore/Sources/LChatCore/Crypto.swift +24 −3
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +40 −0
  • ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift +123 −0
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +13 −0
  • ios/LChatCore/Sources/LChatCore/Nicknames.swift +59 −0
  • ios/LChatCore/Sources/LChatCore/RenameBanner.swift +45 −0
  • ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +210 −8
  • ios/LChatCore/Tests/LChatCoreTests/Fixtures/nicknames-vector.json +20 −0
  • ios/LChatCore/Tests/LChatCoreTests/NicknamesTests.swift +32 −0
  • ios/LChatCore/Tests/LChatCoreTests/NicknamesVectorTests.swift +42 −0
  • ios/LChatMac/AppThreadWebView.swift +3 −0
  • ios/LChatMac/CommandPanelWebView.swift +3 −0
  • ios/LChatMac/GroupSheets.swift +21 −7
  • ios/LChatMac/NativeChatView.swift +23 −0
  • ios/LChatMac/NativeFeedView.swift +1 −1
  • ios/LChatNSE/NotificationService.swift +11 −3
  • package.json +1 −1
  • test/nicknames.test.mjs +75 −0
  • web/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");