eleven / messenger Public

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>
Paul Campbell 499dc22704caafdbb4c037cb9d73fa8b85be0d04 parent d2918f7
17 files changed, +187 −10
  • changelog/2026-07-10-sender-names-always-show.md +4 −0
  • ios/LChat/Views/ElevenChatScreen.swift +9 −0
  • ios/LChat/Views/NativeChatScreen.swift +3 −1
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +29 −0
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +6 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +7 −2
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +16 −0
  • ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift +1 −1
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +4 −0
  • ios/LChatCore/Tests/LChatCoreTests/SenderNameTests.swift +37 −0
  • ios/LChatMac/ElevenConversationPane.swift +7 −0
  • ios/LChatMac/NativeFeedView.swift +3 −1
  • test/apps.test.mjs +13 −0
  • test/ui.test.mjs +16 −0
  • web/static/apps/chat.js +12 −5
  • web/static/shell.js +7 −0
  • web/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