web+ios+mac: resolve sender names above bubbles by uid at render time (#592)
The name above a group-chat bubble was the snapshot the sender stamped into each encrypted payload at send time, with no fallback β so a member who sent messages before choosing a name (browser joins claim the session nameless) stayed permanently nameless, and history kept a stale name after a rename. Bubble headers now resolve the sender's CURRENT roster label by the uid inside the ciphertext, falling back to the payload snapshot for uid-less legacy messages and departed members. Sender runs group by uid too (twin helpers: sameSender in ui.js and ChatFeedModel.sameSender), so a rename doesn't split a run and two nameless senders never merge into one. Also fixes the native welcome flow's setMyName: it published the label but never updated SavedInstance.screenName, so new messages kept stamping the old (or empty) name β hosts now persist it like the manual rename paths.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
17 files changed,
+187
−10
changelog/2026-07-10-sender-names-always-show.md+4 −0ios/LChat/Views/ElevenChatScreen.swift+9 −0ios/LChat/Views/NativeChatScreen.swift+3 −1ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+29 −0ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift+6 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+7 −2ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift+16 −0ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift+1 −1ios/LChatCore/Sources/LChatCore/ThreadStore.swift+4 −0ios/LChatCore/Tests/LChatCoreTests/SenderNameTests.swift+37 −0ios/LChatMac/ElevenConversationPane.swift+7 −0ios/LChatMac/NativeFeedView.swift+3 −1test/apps.test.mjs+13 −0test/ui.test.mjs+16 −0web/static/apps/chat.js+12 −5web/static/shell.js+7 −0web/static/ui.js+13 −0
diff --git a/changelog/2026-07-10-sender-names-always-show.md b/changelog/2026-07-10-sender-names-always-show.md| new file mode 100644 |
| index 0000000..1e0e15d |
| --- /dev/null |
| +++ b/changelog/2026-07-10-sender-names-always-show.md |
| @@ -0,0 +1,4 @@ |
| +π€ Names above messages in group chats now always show the sender's current |
| +name. Messages someone sent before choosing their name used to stay nameless |
| +forever, and old messages kept showing an old name after a rename β both now |
| +show the name the sender goes by today. |
diff --git a/ios/LChat/Views/ElevenChatScreen.swift b/ios/LChat/Views/ElevenChatScreen.swift| index 8c5780a..07dd55e 100644 |
| --- a/ios/LChat/Views/ElevenChatScreen.swift |
| +++ b/ios/LChat/Views/ElevenChatScreen.swift |
| @@ -37,6 +37,15 @@ struct ElevenChatScreen: View { |
| } |
| } |
| } |
| + // Outgoing payloads stamp the SavedInstance's name β persist a |
| + // flow rename, the way ServerSettingsSheet persists a manual one. |
| + m?.onNameSet = { name in |
| + var instances = InstancesStore.load() |
| + if let i = instances.firstIndex(where: { $0.id == instance.id }) { |
| + instances[i].screenName = name |
| + InstancesStore.save(instances) |
| + } |
| + } |
| m?.start(roster: store.roster, threads: store.threads) |
| model = m |
| } |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 0fa712c..3989656 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -730,7 +730,7 @@ struct NativeChatScreen: View { |
| private func row(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?, |
| proxy: ScrollViewProxy) -> some View { |
| let sep = timeSeparator(at: m.at, previous: previous?.at) |
| - let startsRun = sep != nil || previous?.name != m.name |
| + let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, m) |
| VStack(spacing: 0) { |
| if let sep { |
| separator(sep, at: m.at) |
| @@ -747,6 +747,7 @@ struct NativeChatScreen: View { |
| HStack(spacing: 0) { |
| if m.mine { Spacer(minLength: 40) } |
| MessageBubble(message: m, showSender: isGroup && startsRun, |
| + senderName: model.senderName(m), |
| theme: .named(themeName), |
| onJump: { target in jumpAndWave(target, proxy: proxy) }, |
| onOpenThread: { root in openThread(root) }, |
| @@ -859,6 +860,7 @@ struct NativeChatScreen: View { |
| private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View { |
| MessageBubble(message: m, showSender: showSender, |
| + senderName: model.senderName(m), |
| theme: .named(themeName), |
| blobSource: { model.blobSource($0) }, |
| onImageTap: { att, image, _ in |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index b628f68..7c62f98 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -61,8 +61,37 @@ public final class ChatFeedModel: ObservableObject { |
| m.mine && !m.action && Date().timeIntervalSince1970 - TimeInterval(m.at) < 15 * 60 |
| } |
| + /// The name to show above a bubble: the sender's CURRENT roster name |
| + /// (looked up by the uid inside the ciphertext), falling back to the name |
| + /// snapshot the sender stamped into the payload at send time. The lookup |
| + /// heals messages sent before a member picked a name β permanently |
| + /// nameless snapshots β and retitles history after a rename; the snapshot |
| + /// still covers uid-less legacy messages and members gone from the roster. |
| + /// Twin: the senderName resolution in chat.js (buildMessageEl). |
| + public func senderName(_ m: Message) -> String { |
| + if let uid = m.raw.uid, let label = senderNames[uid], !label.isEmpty { return label } |
| + return m.name |
| + } |
| + |
| + /// Whether two feed messages came from the same sender β the sender-run |
| + /// rule (consecutive same-sender bubbles share one name header). Prefer |
| + /// the uid (stable across renames, present even when the sender hadn't |
| + /// picked a name yet); the payload's name snapshot is only the tiebreak |
| + /// for uid-less legacy messages, and two nameless legacy messages never |
| + /// group β "" == "" says nothing about who sent them. |
| + /// Twin: sameSender in web/static/ui.js (chat.js viewItems). |
| + public static func sameSender(_ a: Message?, _ b: Message) -> Bool { |
| + guard let a else { return false } |
| + if let au = a.raw.uid, !au.isEmpty, let bu = b.raw.uid, !bu.isEmpty { return au == bu } |
| + return !a.name.isEmpty && a.name == b.name |
| + } |
| + |
| @Published public private(set) var messages: [Message] = [] |
| @Published public private(set) var status = "loadingβ¦" |
| + /// The roster's current decrypted names by uid, seeded and refreshed by |
| + /// NativeFeedModelCache from each ThreadStore fold β the bubble-header |
| + /// lookup (see senderName below). |
| + @Published public var senderNames: [String: String] = [:] |
| /// Names currently typing (insertion order), for typingSummary above the |
| /// composer. Fed by ephemeral chat.typing beats; entries expire on their |
| /// own clock. |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift| index 52b6645..3e9617f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| @@ -55,6 +55,11 @@ public final class ElevenConversationModel: ObservableObject { |
| /// The finish chip's Admin hop: open (creating if needed) the Admin app |
| /// thread β the host owns thread creation and navigation. |
| public var openAdmin: (() -> Void)? |
| + /// The welcome flow's name step just published a label β the host must |
| + /// also persist the name into the saved instance (outgoing payloads stamp |
| + /// SavedInstance.screenName; without this a flow rename fixes the roster |
| + /// but new messages keep carrying the old β or empty β name snapshot). |
| + public var onNameSet: ((String) -> Void)? |
| public init?(instance: SavedInstance, thread: ChatThread, myUserId: String) { |
| guard let appId = thread.appId, appId == "setup" || appId == "welcome", |
| @@ -299,6 +304,7 @@ extension ElevenConversationModel: FlowIO { |
| let capped = String(raw.prefix(32)) |
| guard !capped.isEmpty else { return .bool(false) } |
| await publishLabel(capped) |
| + onNameSet?(capped) |
| return .null |
| case "setGroupName": |
| let name = (args.first?.stringValue ?? "").trimmingCharacters(in: .whitespaces) |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index f53f71a..0088f04 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -80,6 +80,9 @@ public struct MessageBubble: View { |
| public let message: ChatFeedModel.Message |
| /// Show the sender name above the bubble (group chats, first of a run). |
| public let showSender: Bool |
| + /// The resolved sender name to show β the model's roster-by-uid lookup |
| + /// (ChatFeedModel.senderName). Nil falls back to the payload's snapshot. |
| + public let senderName: String? |
| public let theme: ChatTheme |
| /// Jump to another message (a quote's parent, or the first reply) β the |
| /// container owns scrolling, the leaf just asks. |
| @@ -125,6 +128,7 @@ public struct MessageBubble: View { |
| @Environment(\.inReplyThread) private var inReplyThread |
| public init(message: ChatFeedModel.Message, showSender: Bool, |
| + senderName: String? = nil, |
| theme: ChatTheme = .classic, onJump: ((Int64) -> Void)? = nil, |
| onOpenThread: ((Int64) -> Void)? = nil, |
| blobSource: ((Attachment) -> AvatarSource?)? = nil, |
| @@ -139,6 +143,7 @@ public struct MessageBubble: View { |
| onYouTube: ((String, Int, CGRect) -> Void)? = nil) { |
| self.message = message |
| self.showSender = showSender |
| + self.senderName = senderName |
| self.theme = theme |
| self.onJump = onJump |
| self.onOpenThread = onOpenThread |
| @@ -157,8 +162,8 @@ public struct MessageBubble: View { |
| public var body: some View { |
| VStack(alignment: message.mine ? .trailing : .leading, spacing: 3) { |
| - if showSender, !message.mine, !message.name.isEmpty { |
| - Text(message.name) |
| + if showSender, !message.mine, !(senderName ?? message.name).isEmpty { |
| + Text(senderName ?? message.name) |
| .font(.system(size: 12)) |
| .foregroundStyle(.secondary) |
| .padding(.horizontal, 6) |
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift| index e031ab0..b422cfe 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift |
| @@ -21,6 +21,21 @@ public final class NativeFeedModelCache { |
| private var entries: [String: Entry] = [:] // "instanceID|threadID" |
| private let maxEntries = 8 |
| + // The latest decrypted roster names per instance (uid β name) β seeded |
| + // into every model this cache hands out and pushed into parked ones on |
| + // each ThreadStore fold, so bubble headers resolve against the current |
| + // roster without a model rebuild. |
| + private var senderNames: [String: [String: String]] = [:] |
| + |
| + /// 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]) { |
| + senderNames[instanceID] = labels |
| + for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| + if entry.model.senderNames != labels { entry.model.senderNames = labels } |
| + } |
| + } |
| + |
| /// The live model for a thread β reused (and re-marked recent) when parked, |
| /// created and started when not. |
| public func model(for instance: SavedInstance, thread: ChatThread, myUserId: String) -> ChatFeedModel { |
| @@ -37,6 +52,7 @@ public final class NativeFeedModelCache { |
| } |
| entries.removeValue(forKey: key)?.model.stop() |
| let model = ChatFeedModel(instance: instance, thread: thread, myUserId: myUserId) |
| + model.senderNames = senderNames[instance.id] ?? [:] |
| model.start() |
| entries[key] = Entry(model: model, seedKey: thread.threadKeyB64) |
| evictIfNeeded() |
diff --git a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift| index 0f58959..d609fb7 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift |
| @@ -259,7 +259,7 @@ public struct ReplyThreadView<Row: View>: View { |
| // panel reuses): the first reply opens with a dated separator, then |
| // day-change/15-minute rules; a run only continues uninterrupted. |
| let sep = timeSeparator(at: m.at, previous: previous?.at) |
| - let startsRun = sep != nil || previous?.name != m.name |
| + let startsRun = sep != nil || !ChatFeedModel.sameSender(previous, m) |
| if let sep { |
| Group { |
| if sep == .dated { |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 6201d87..140fbdf 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -94,6 +94,10 @@ public final class ThreadStore: ObservableObject { |
| instanceKeyB64 = folded.instanceKeyB64 |
| roster = folded.people |
| rosterLabels = folded.rosterLabels |
| + // Hand the fresh names to the native feed models (live and parked) β |
| + // bubble headers resolve senders by uid against the current roster. |
| + NativeFeedModelCache.shared.updateSenderNames(instance: instance.id, |
| + labels: folded.rosterLabels) |
| // 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 |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SenderNameTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SenderNameTests.swift| new file mode 100644 |
| index 0000000..71fea83 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/SenderNameTests.swift |
| @@ -0,0 +1,37 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// π€ Twins of the web's sender-attribution helpers (sameSender in ui.js, the |
| +// senderName resolution in chat.js) β the run-grouping and bubble-header rules |
| +// must fold identically on both platforms, like Presence/presenceLabel. |
| +@MainActor |
| +final class SenderNameTests: XCTestCase { |
| + |
| + private func msg(uid: String?, name: String) -> ChatFeedModel.Message { |
| + var raw = MessagePayload() |
| + raw.uid = uid |
| + raw.name = name |
| + return ChatFeedModel.Message(id: 1, name: name, text: "hi", mine: false, |
| + at: 1_700_000_000, edited: false, raw: raw) |
| + } |
| + |
| + func testSameSenderPrefersUid() { |
| + // Same person across a rename (snapshots differ) β still one run. |
| + XCTAssertTrue(ChatFeedModel.sameSender(msg(uid: "u1", name: "Dermo"), |
| + msg(uid: "u1", name: "Dermot"))) |
| + // Two people, even with identical snapshots (both nameless), never group. |
| + XCTAssertFalse(ChatFeedModel.sameSender(msg(uid: "u1", name: ""), |
| + msg(uid: "u2", name: ""))) |
| + } |
| + |
| + func testSameSenderLegacyFallsBackToName() { |
| + XCTAssertTrue(ChatFeedModel.sameSender(msg(uid: nil, name: "Al"), |
| + msg(uid: nil, name: "Al"))) |
| + XCTAssertFalse(ChatFeedModel.sameSender(msg(uid: nil, name: "Al"), |
| + msg(uid: nil, name: "Bo"))) |
| + // A nameless legacy pair says nothing about who sent them β no grouping. |
| + XCTAssertFalse(ChatFeedModel.sameSender(msg(uid: nil, name: ""), |
| + msg(uid: nil, name: ""))) |
| + XCTAssertFalse(ChatFeedModel.sameSender(nil, msg(uid: "u1", name: "Al"))) |
| + } |
| +} |
diff --git a/ios/LChatMac/ElevenConversationPane.swift b/ios/LChatMac/ElevenConversationPane.swift| index 8a123ca..b58e0ac 100644 |
| --- a/ios/LChatMac/ElevenConversationPane.swift |
| +++ b/ios/LChatMac/ElevenConversationPane.swift |
| @@ -9,6 +9,7 @@ struct ElevenConversationPane: View { |
| let thread: ChatThread |
| @ObservedObject var store: ThreadStore |
| @Binding var selectedID: String? |
| + @EnvironmentObject private var app: MacAppState |
| @State private var model: ElevenConversationModel? |
| @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize |
| @AppStorage("nativeTheme") private var themeName = "classic" |
| @@ -34,6 +35,12 @@ struct ElevenConversationPane: View { |
| } |
| } |
| } |
| + // Outgoing payloads stamp the SavedInstance's name β persist a |
| + // flow rename, the way MacSettingsView persists a manual one. |
| + m?.onNameSet = { name in |
| + app.noteIdentity(origin: instance.origin, token: instance.deviceToken, |
| + name: name, userPub: instance.userPub, userPriv: instance.userPriv) |
| + } |
| m?.start(roster: store.roster, threads: store.threads) |
| model = m |
| } |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index a76eb98..7b75e69 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -578,7 +578,7 @@ struct NativeFeedView: View { |
| // the feed opens or the day changes, time-only past a 15-minute gap; |
| // a same-sender run only continues when no separator intervenes. |
| let separator = timeSeparator(at: m.at, previous: previous?.at) |
| - let startsRun = separator != nil || previous?.name != m.name |
| + let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m) |
| if let separator { |
| HStack(spacing: 4) { |
| if separator == .dated { |
| @@ -595,6 +595,7 @@ struct NativeFeedView: View { |
| HStack(spacing: 0) { |
| if m.mine { Spacer(minLength: 48) } |
| MessageBubble(message: m, showSender: isGroup && startsRun, |
| + senderName: model.senderName(m), |
| theme: theme, |
| onJump: { target in jump(to: target, proxy: proxy) }, |
| onOpenThread: { root in openThread(root) }, |
| @@ -1230,6 +1231,7 @@ struct NativeFeedView: View { |
| private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View { |
| MessageBubble(message: m, showSender: showSender, |
| + senderName: model.senderName(m), |
| theme: theme, |
| blobSource: { att in model.blobSource(att) }, |
| onImageTap: { att, image, frame in |
diff --git a/test/apps.test.mjs b/test/apps.test.mjs| index cd931f2..1e73bd7 100644 |
| --- a/test/apps.test.mjs |
| +++ b/test/apps.test.mjs |
| @@ -1051,6 +1051,19 @@ await test("chat: viewItems derives time separators and sender runs (original fe |
| assert.notEqual(v[1].vrev, v[2].vrev); |
| }); |
| +await test("chat: viewItems groups runs by uid β a rename doesn't split, nameless senders don't merge", () => { |
| + const base = Date.UTC(2026, 6, 1, 12, 0, 0); |
| + const v = viewItems([ |
| + { id: "m1", sid: 1, uid: "u1", name: "Dermo", ts: base, rev: 0 }, |
| + { id: "m2", sid: 2, uid: "u1", name: "Dermot", ts: base + 1000, rev: 0 }, |
| + { id: "m3", sid: 3, uid: "u2", name: "", ts: base + 2000, rev: 0 }, |
| + { id: "m4", sid: 4, uid: "u3", name: "", ts: base + 3000, rev: 0 }, |
| + ]); |
| + assert.equal(v[1].cont, true, "same uid continues the run across a rename"); |
| + assert.equal(v[2].cont, false); |
| + assert.equal(v[3].cont, false, "different uids never group, even both nameless"); |
| +}); |
| + |
| await test("chat: threadItems selects a root's whole thread β legacy chains resolve into it", () => { |
| const msgs = [ |
| { id: "m1", sid: 1, name: "Al", text: "root", ts: 1, reply: null, rev: 0 }, |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 5ea6abc..22e00e9 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -12,6 +12,7 @@ import { |
| composerToken, |
| pinnedFirst, |
| ownMessage, |
| + sameSender, |
| memberCountLabel, |
| onlineFilterKeeps, |
| chatCategory, |
| @@ -226,6 +227,21 @@ await test("ownMessage: never matches on empty identity", () => { |
| assert.equal(ownMessage({}, "u1", "Paul"), false); |
| }); |
| +await test("sameSender: uid wins over the name snapshot", () => { |
| + // Same person across a rename (snapshots differ) β still one run. |
| + assert.equal(sameSender({ uid: "u1", name: "Dermo" }, { uid: "u1", name: "Dermot" }), true); |
| + // Two people, even with identical snapshots (both nameless), never group. |
| + assert.equal(sameSender({ uid: "u1", name: "" }, { uid: "u2", name: "" }), false); |
| +}); |
| + |
| +await test("sameSender: uid-less legacy messages fall back to the name", () => { |
| + assert.equal(sameSender({ name: "Al" }, { name: "Al" }), true); |
| + assert.equal(sameSender({ name: "Al" }, { name: "Bo" }), false); |
| + // A nameless legacy pair says nothing about who sent them β no grouping. |
| + assert.equal(sameSender({ name: "" }, { name: "" }), false); |
| + assert.equal(sameSender(null, { name: "Al" }), false); |
| +}); |
| + |
| await test("sanitizeActions: whitelists types and validates params", () => { |
| const chips = sanitizeActions([ |
| { type: "set_theme", theme: "plum" }, |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 5c0a65f..55f74e6 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -26,7 +26,7 @@ import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, ren |
| import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| import { renderCard, CARD_CSS } from "../cards.js"; |
| import { emojify } from "../emoji.js"; |
| -import { fileIcon, shouldSend, composerToken, ownMessage } from "../ui.js"; |
| +import { fileIcon, shouldSend, composerToken, ownMessage, sameSender } from "../ui.js"; |
| import { decodeBlurhash } from "../blurhash.js"; |
| // The host bridge: chat.js injects the pieces that are server mechanisms, |
| @@ -384,7 +384,7 @@ export function viewItems(msgs) { |
| const newDay = !sameDay(m.ts, prev.ts); |
| if (newDay || m.ts - prev.ts > TIME_SEPARATOR_GAP_MS) timeSep = { withDay: newDay }; |
| } |
| - const cont = !timeSep && !!prev && prev.name === m.name; |
| + const cont = !timeSep && sameSender(prev, m); |
| const replyCount = replyCounts.get(m.sid) || 0; |
| // The soft rev is derived purely from reactions + tally (never myReactions β |
| // viewItems stays pure/exported/tested); a chip's count moves on every |
| @@ -826,13 +826,20 @@ function renderItem(m, ctx) { |
| // jump on the root finds legacy chain replies too. |
| if (m.reply && m.reply.id) wrap.dataset.replyTo = m.replyRoot || m.reply.id; |
| - if (!mine && m.name && host && host.isGroup && host.isGroup(ctx.threadId) && !m.cont) { |
| + // The header shows the sender's CURRENT roster name (looked up by the uid |
| + // inside the ciphertext), falling back to the name snapshot the sender |
| + // stamped into the payload. The lookup heals messages sent before a member |
| + // picked a name β permanently nameless snapshots β and retitles history |
| + // after a rename; the snapshot still covers uid-less legacy messages and |
| + // members who've since left the roster. |
| + const senderName = (m.uid && host && host.memberName && host.memberName(m.uid)) || m.name; |
| + if (!mine && senderName && host && host.isGroup && host.isGroup(ctx.threadId) && !m.cont) { |
| const nm = document.createElement("div"); |
| nm.className = "name"; |
| nm.style.display = "flex"; // center the name text with its AI/BOT badge |
| nm.style.alignItems = "center"; |
| - nm.textContent = m.name; |
| - const kind = host.agentKind && host.agentKind(m.name); |
| + nm.textContent = senderName; |
| + const kind = host.agentKind && host.agentKind(senderName); |
| if (kind) nm.appendChild(agentTag(kind)); |
| wrap.appendChild(nm); |
| } |
diff --git a/web/static/shell.js b/web/static/shell.js| index ad6b6aa..97110f8 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -3178,6 +3178,13 @@ setChatAppHost({ |
| } |
| }, |
| loadOlder: (threadId, beforeSid) => chatAppLoadOlder(threadId, beforeSid), |
| + // A member's current (client-decrypted) roster name by uid, "" when unknown β |
| + // the bubble-header lookup. Deliberately NOT rosterLabel: no "Someone" |
| + // fallback, so the renderer can fall through to the payload's name snapshot. |
| + memberName: (uid) => { |
| + const e = roster.find((x) => x.user_id === uid); |
| + return (e && e.label) || ""; |
| + }, |
| // Mentions: the roster's (client-decrypted) display names, and the original |
| // tap-a-mention member menu (Message them β¦). |
| mentionNames: () => roster.map((e) => e.label).filter(Boolean), |
diff --git a/web/static/ui.js b/web/static/ui.js| index 0ab15b4..e6ce518 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -101,6 +101,19 @@ export function ownMessage(payload, myUserId, myName) { |
| return !!myName && payload.name === myName; |
| } |
| +// Whether two feed messages came from the same sender β the sender-run rule |
| +// (consecutive same-sender bubbles share one name header). Prefer the uid |
| +// (inside the ciphertext, stable across renames and present even when the |
| +// sender hadn't picked a name yet); the payload's name snapshot is only the |
| +// tiebreak for uid-less legacy messages. Two nameless legacy messages never |
| +// group: "" === "" says nothing about who sent them. |
| +// Twin: ChatFeedModel.sameSender (native renders the same runs). |
| +export function sameSender(a, b) { |
| + if (!a || !b) return false; |
| + if (a.uid && b.uid) return a.uid === b.uid; |
| + return !!a.name && a.name === b.name; |
| +} |
| + |
| // ---- group header --------------------------------------------------------- |
| // The member-count subtitle for a group header, counting people and bots |
| // separately ("2 members, 3 bots") so a room's bot presence is visible at a |