eleven / ocho Public

web+native: stamp my published name when the device hasn't got one (#1419)

Follow-up to the reaction previews (#1418), where "Someone reacted 😂" showed
up on real data. That was fixed on the read side (resolve the actor by uid);
this fixes the write side, which is what made the payloads nameless.

Outgoing payloads stamp the DEVICE's copy of my name — session.name on web,
instance.screenName on native — and both are routinely empty. The chat-first
join claims nameless and lets Eleven ask in-chat; a member who answered on
another device leaves this one blank forever; a space claimed from anything
but an add-device link starts with "". Everything stamped in that window
reads as "Someone" wherever the payload's own name is the only source
(sidebar previews, push copy, the NSE's communication banner).

Both sides now fall back to my published roster label — the name every other
member already sees. Never a nickname: this rides where others read it. Local
name first, so a rename applies before its label finishes publishing.

Native also had a latent bug from the same empty string: the uid-less "is
this mine?" fallback compares the payload's name to ours, so two empties
matched and attributed a stranger's legacy message to me. Guarded, mirroring
ownMessage in ui.js.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam pushed by codex agent, operated by paul@keymail.dev a112222c9298dc776a2985d1de34deea63f7209b parent 9e24de6
3 files changed, +49 −19
  • changelog/2026-08-05-sender-names.md +4 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +29 −17
  • web/static/shell.js +16 −2
diff --git a/changelog/2026-08-05-sender-names.md b/changelog/2026-08-05-sender-names.md
new file mode 100644
index 0000000..10ff365
--- /dev/null
+++ b/changelog/2026-08-05-sender-names.md
@@ -0,0 +1,4 @@
+🤖 **Fewer messages from "Someone".** If you'd set your name on one device but
+not another, anything sent from the second one arrived unsigned — showing as
+"Someone" in chat-list previews and notifications for everyone else. Your name
+now travels with what you send, whichever device you sent it from.
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 8e25e71..b13f706 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -162,8 +162,20 @@ public final class ChatFeedModel: ObservableObject {
private var lastHistoryLoad = Date.distantPast
private let instance: SavedInstance
- /// The sender's screen name — the composer needs it to build a /me line.
- public var myScreenName: String { instance.screenName }
+ /// The sender's screen name — stamped into every outgoing payload, and the
+ /// composer's /me line.
+ ///
+ /// `instance.screenName` is THIS DEVICE's copy, and it is routinely empty:
+ /// a space claimed from anything but an add-device link starts nameless
+ /// (SpaceCreate.claim) and the setup conversation names us later — on
+ /// whichever device answered, so a second device keeps its blank forever.
+ /// Everything it stamped then read as "Someone" wherever a payload's own
+ /// `name` is the only source (sidebar previews, push copy). So fall back to
+ /// my roster label: the name every other member already sees. Web twin:
+ /// stampName() in shell.js.
+ public var myScreenName: String {
+ instance.screenName.isEmpty ? (senderNames[myUserId] ?? "") : instance.screenName
+ }
private let threadID: String
private let threadKind: String
private let threadKeyB64: String
@@ -599,7 +611,7 @@ public final class ChatFeedModel: ObservableObject {
when: Double? = nil, sid: String? = nil) -> Bool {
guard !text.isEmpty, key != nil, let socket, socket.isLive else { return false }
var payload = AppEventOut(type: type, id: id, text: text,
- name: instance.screenName,
+ name: myScreenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
payload.when = when
@@ -643,7 +655,7 @@ public final class ChatFeedModel: ObservableObject {
let id = UUID().uuidString
var sid: String? = nil
var fire = RemindFireOut(id: id, notice: .init(title: "⏰ Reminder", body: text),
- name: instance.screenName,
+ name: myScreenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { fire.uid = myUserId }
// The seconds conversion must never trap: Int64(Double) crashes on
@@ -747,7 +759,7 @@ public final class ChatFeedModel: ObservableObject {
guard threadKind == "group", !app.isEmpty, key != nil,
let socket, socket.isLive else { return false }
var payload = GroupAppsOut(type: add ? "groupapps.add" : "groupapps.remove",
- app: app, name: instance.screenName,
+ app: app, name: myScreenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
encryptAndSend(payload, via: socket)
@@ -755,7 +767,7 @@ public final class ChatFeedModel: ObservableObject {
// right after the add, exactly like the web's installGroupApp —
// without it the app renders silently empty for every member.
if add, let ev = registry.manifest(app)?.openEvent, !ev.isEmpty {
- var open = OpenEventOut(type: ev, name: instance.screenName,
+ var open = OpenEventOut(type: ev, name: myScreenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { open.uid = myUserId }
encryptAndSend(open, via: socket)
@@ -787,7 +799,7 @@ public final class ChatFeedModel: ObservableObject {
@discardableResult
public func sendAction(_ text: String) -> Bool {
guard !text.isEmpty, key != nil, let socket, socket.isLive else { return false }
- var payload = Outgoing(text: text, name: instance.screenName,
+ var payload = Outgoing(text: text, name: myScreenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
payload.action = true
@@ -833,7 +845,7 @@ public final class ChatFeedModel: ObservableObject {
var quoted = m.raw
quoted.text = markdownPlainText(m.raw.text ?? "")
var payload = MessagePayload(type: "reaction",
- name: instance.screenName,
+ name: myScreenName,
ts: Int64(Date().timeIntervalSince1970 * 1000),
targetSid: id,
targetUid: m.raw.uid,
@@ -891,7 +903,7 @@ public final class ChatFeedModel: ObservableObject {
guard !trimmed.isEmpty || !attachments.isEmpty, key != nil,
let socket, socket.isLive else { return false }
let ts = Int64(Date().timeIntervalSince1970 * 1000)
- var payload = Outgoing(text: trimmed, name: instance.screenName, ts: ts)
+ var payload = Outgoing(text: trimmed, name: myScreenName, ts: ts)
if !myUserId.isEmpty { payload.uid = myUserId }
if !attachments.isEmpty { payload.attachments = attachments }
payload.replyTo = replyTo
@@ -916,7 +928,7 @@ public final class ChatFeedModel: ObservableObject {
public func sendViaREST(_ text: String, attachments: [Attachment]) async -> Bool {
guard let key, let base = instance.baseURL,
!text.isEmpty || !attachments.isEmpty else { return false }
- var payload = Outgoing(text: emojify(text), name: instance.screenName,
+ var payload = Outgoing(text: emojify(text), name: myScreenName,
ts: Int64(Date().timeIntervalSince1970 * 1000))
if !myUserId.isEmpty { payload.uid = myUserId }
if !attachments.isEmpty { payload.attachments = attachments }
@@ -1024,7 +1036,7 @@ public final class ChatFeedModel: ObservableObject {
guard s.payload.isRenderableChat else { return nil }
let p = s.payload
let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
- ?? (p.name == instance.screenName)
+ ?? (!myScreenName.isEmpty && p.name == myScreenName)
return Message(id: s.id, name: p.name ?? "", text: p.text ?? "",
mine: mine, at: s.at, action: p.action ?? false,
edited: s.edited, attachments: p.attachments ?? [],
@@ -1091,7 +1103,7 @@ public final class ChatFeedModel: ObservableObject {
}
}
let myUserId = self.myUserId
- let screenName = self.instance.screenName
+ let screenName = self.myScreenName
let wantGroupApps = threadKind == "group"
let foldSpan = Perf.begin("feed.foldPage", slowMs: 250)
let (fresh, stored, appEvents, reactionRows, unprojectedRows):
@@ -1134,7 +1146,7 @@ public final class ChatFeedModel: ObservableObject {
reactions: [], payload: p))
guard p.isRenderableChat else { continue }
let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
- ?? (p.name == screenName)
+ ?? (!screenName.isEmpty && p.name == screenName)
out.append(Message(id: row.id, name: p.name ?? "", text: p.text ?? "",
mine: mine, at: row.created_at,
action: p.action ?? false,
@@ -1369,7 +1381,7 @@ public final class ChatFeedModel: ObservableObject {
guard p.type == "chat.typing", let name = p.name, !name.isEmpty else { return }
if let uid = p.uid, !myUserId.isEmpty {
if uid == myUserId { return }
- } else if name == instance.screenName {
+ } else if !myScreenName.isEmpty, name == myScreenName {
return
}
let secs = min(max(p.expiry ?? 0, 0), 60)
@@ -1429,7 +1441,7 @@ public final class ChatFeedModel: ObservableObject {
private func sendTypingBeat(expiry: Int) {
guard let key, let socket else { return }
- var payload = TypingOut(name: instance.screenName,
+ var payload = TypingOut(name: myScreenName,
ts: Int64(Date().timeIntervalSince1970 * 1000), expiry: expiry)
if !myUserId.isEmpty { payload.uid = myUserId }
lastBeatSent = Date()
@@ -1582,7 +1594,7 @@ public final class ChatFeedModel: ObservableObject {
// Own-message check mirrors the web (ownMessage in ui.js): uid when the
// payload carries one, else the screen name (old payloads).
let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
- ?? (p.name == instance.screenName)
+ ?? (!myScreenName.isEmpty && p.name == myScreenName)
// The client id to pair on: the explicit optimistic-fold argument, else
// the one riding in the echo's payload. A confirming echo has pending ==
// false and reads it from `p._cid`; a fresh optimistic copy passes it in.
@@ -1658,7 +1670,7 @@ public final class ChatFeedModel: ObservableObject {
pendingIDSeq &+= 1
var p = MessagePayload()
p.text = text
- p.name = instance.screenName
+ p.name = myScreenName
p.ts = ts
if !myUserId.isEmpty { p.uid = myUserId }
if !attachments.isEmpty { p.attachments = attachments }
diff --git a/web/static/shell.js b/web/static/shell.js
index f50df7f..1e7dd1a 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -2006,6 +2006,20 @@ function backfillLegacyLabels() {
}
}
+// The name outgoing payloads stamp. session.name is THIS DEVICE's copy, and it
+// is routinely empty: the chat-first join claims nameless and lets Eleven ask
+// in-chat (loadSession says so), and a member who answered on another device
+// leaves this one blank forever. Everything stamped in that window reads as
+// "Someone" wherever the payload's own name is the only source (sidebar
+// previews, push copy). So fall back to my PUBLISHED roster label — never a
+// nickname: this rides where other members read it. Local name first, so a
+// rename takes effect before its label finishes publishing.
+function stampName() {
+ if (session.name) return session.name;
+ const me = roster.find((e) => e.user_id === myUserId);
+ return (me && me.label) || "";
+}
+
// Publish our own display name, encrypted under the instance key — for a fresh
// joiner, and to migrate anyone whose name is still stored in plaintext. The
// server only ever stores the blob.
@@ -4395,7 +4409,7 @@ setEmitTransport(async (threadId, event) => {
// verbatim — that's how we pair our optimistic local copy with its
// authoritative confirmation and replace it silently (see optimisticEmit).
const cid = newCid();
- const payload = { ...event, name: session.name, ts: Date.now(), _cid: cid };
+ const payload = { ...event, name: stampName(), ts: Date.now(), _cid: cid };
if (myUserId) payload.uid = myUserId;
// Tuck our verified chain head into the encrypted payload (tamper-evidence
// gossip) — peers compare it against their own view; apps never see it.
@@ -5254,7 +5268,7 @@ setChatAppHost({
targetSid,
emoji,
on,
- name: session.name || "",
+ name: stampName(),
uid: myUserId,
ts: Date.now(),
};