eleven / messenger Public

native+web: the voice surfaces — one event speaks properly everywhere (#1318)

Field direction (James, 2026-07-23): every app event needs a say over its
chat line, its thread-list preview, and its notification — with the last
two defaulting to each other — and the built-ins must stop dropping bare
"added 'X'" lines into group chats.

The contract (web/static/apps/README.md § Voice):
- chat: chatMessage/groupFeedMessage return ACTOR-NEUTRAL lines; the host
  prefixes You/name. Group wording says where the data went.
- preview: preview(e, {you}) — you = the actor is the reader. No hook →
  the actor-prefixed chat projection speaks for the sidebar (appPreview's
  new default chain).
- notification: notice → the same chain → silent.

Wording (vector-pinned, both platforms + test/event-vectors/todo.json +
the schema-vectors fixture): group todo.add = "added "X" to the to-dos";
group remind.add = "set a reminder: "X" for <time>"; personal threads stay
terse. The legacy vector text's straight quotes aligned to the curly ones
both implementations actually emit.

Native:
- renderable()/fillProjections bake the actor into GROUP action lines
  ("You added…") — the Mac/iOS action row renders text alone, so the web's
  host-side prefix never reached it; the Mac banner stops double-naming.
- ThreadStore previews stop showing an app event's raw payload text: the
  chat projection is the fast path, and the app's own preview hook answers
  through the app host (previewEvent on the bridge; cached per thread ×
  last_at) and overrides. The day-one sidebar divergence closes.

swift test: 378 pass; npm green (wording pins updated deliberately — the
contract change process, not tests-made-to-pass).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
James Adam 6cb7e4028a666e2792b046c2a6f18d709f3282e1 parent f7700a4
17 files changed, +273 −36
  • docs/native-apps.md +1 −0
  • ios/LChatCore/Package.resolved +0 −9
  • ios/LChatCore/Sources/LChatCore/AppChatProjection.swift +9 −3
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +23 −5
  • ios/LChatCore/Sources/LChatCore/CommandHost.swift +25 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +76 −2
  • ios/LChatCore/Tests/LChatCoreTests/Fixtures/schema-vectors-todo.json +5 −1
  • ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift +16 −0
  • ios/LChatMac/NativeChatView.swift +8 −2
  • test/apps.test.mjs +1 −1
  • test/event-vectors.test.mjs +20 −0
  • test/event-vectors/todo.json +5 −1
  • web/static/apps.js +21 −3
  • web/static/apps/README.md +22 −0
  • web/static/apps/remind.js +10 −2
  • web/static/apps/todo.js +15 −4
  • web/static/shell.js +16 −3
diff --git a/docs/native-apps.md b/docs/native-apps.md
index 858614c..3d15db7 100644
--- a/docs/native-apps.md
+++ b/docs/native-apps.md
@@ -232,6 +232,7 @@ runs any command, and already reports panels/replies/errors.
| js → native | `{commandHostReady}`, `{panel}`, `{panelHeight}`, `{notice}`, `{composerInsert}`, `{error}` | command-host lifecycle | exists |
| js → native | `{needKey: threadId}` → native calls `__lchatProvideKey(threadId, keyB64)` | cross-thread routing: a personal command typed in a group must emit into the personal app-thread, whose key the single-key embed doesn't hold. Native unwraps (it can only unwrap threads the user is in) and provides. | **phase 3 (shipped)** |
| js → native | `{needAppThread: {app, title}}` → native calls `__lchatProvideAppThread(app, threadId, keyB64)` | the app→thread resolution itself: the embed can't decrypt other threads' name_blobs, so resolving there would mint a duplicate app thread. Native opens-or-creates (`openOrCreateAppThread`) and answers with the id + key. | **phase 3 (shipped)** |
+| native → js | `previewEvent(event, you)` → `{text} \| null` | the sidebar line for one app event — the app's own `preview(e, {you})` hook, defaulting in the engine to the actor-prefixed chat projection (the voice chain: notice → preview → chat projection → silent). `you` = the actor is the reader ("You added…"). | **voice PR (shipped)** |
| native → js | `projectEvent(event, group)` → `{text, action, title} \| null` | chat projection of an app event, for the native feed (companion Chat views, group narration) — the web's `chatMessage`/`groupFeedMessage` answered by the engine that owns them. Ownership resolves in the engine via `appForType` (native never prefix-guesses — `webhooks.*` belongs to webhook-monitor); `title` rides along so native names the bubble. | **phase 5 (shipped)** |
Trust posture: the app host is a privileged surface *by job description* — it
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
index fe25d1c..d82f91f 100644
--- a/ios/LChatCore/Package.resolved
+++ b/ios/LChatCore/Package.resolved
@@ -1,14 +1,5 @@
{
"pins" : [
- {
- "identity" : "sparkle",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/sparkle-project/Sparkle",
- "state" : {
- "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
- "version" : "2.9.4"
- }
- },
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
diff --git a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
index 7dca536..a73368d 100644
--- a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
@@ -88,10 +88,16 @@ public enum AppChatProjection {
false, "remind")
case "remind.add":
let at = e.when.map(absTime) ?? ""
- return ("set a reminder: “\(e.text ?? "")”" + (at.isEmpty ? "" : " — " + at),
- true, "remind")
+ // Group wording says when in prose ("for 9:00") — the line lands
+ // among unrelated conversation; the personal thread keeps the
+ // terse dash. Mirrors remind.js chatMessage exactly.
+ let when = at.isEmpty ? "" : (group ? " for " + at : " — " + at)
+ return ("set a reminder: “\(e.text ?? "")”" + when, true, "remind")
case "todo.add":
- return ("added “\(e.text ?? "")”", true, "todo")
+ // Mirrors todo.js chatMessage: a group line says where the item
+ // went; the personal thread IS the list.
+ return (group ? "added “\(e.text ?? "")” to the to-dos"
+ : "added “\(e.text ?? "")”", true, "todo")
// Adding/removing an app on a group is a visible act, like a rename —
// apps.js groupAppsApp.chatMessage, word for word.
case "groupapps.add":
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 3373d3a..ff21303 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -1074,7 +1074,8 @@ public final class ChatFeedModel: ObservableObject {
reacts.append(p)
continue
}
- guard let p = Self.renderable(row.blob, key: key, group: wantGroupApps)
+ guard let p = Self.renderable(row.blob, key: key, group: wantGroupApps,
+ myUserId: myUserId)
else {
// An app event with no built-in projection: stash it for
// the app-host query (docs/native-apps.md phase 5) — the
@@ -1175,6 +1176,7 @@ public final class ChatFeedModel: ObservableObject {
guard let id = frame.id, let blob = frame.blob, let key else { return }
let at = frame.created_at ?? 0
let wantGroupApps = threadKind == "group"
+ let myUserId = self.myUserId // captured — the decrypt closure runs off-main
// A stored frame is either an encrypted reaction event or a
// chat/app message (with an optional group-apps projection) —
// decrypt off-main either way, then fold the right shape on main.
@@ -1183,7 +1185,8 @@ public final class ChatFeedModel: ObservableObject {
p.isReactionEvent {
return .reaction(p)
}
- let payload = Self.renderable(blob, key: key, group: wantGroupApps)
+ let payload = Self.renderable(blob, key: key, group: wantGroupApps,
+ myUserId: myUserId)
return .message(payload,
wantGroupApps ? Self.groupAppsEvent(blob, key: key) : nil,
payload == nil ? AppChatProjection.unprojected(blob, key: key) : nil)
@@ -1476,10 +1479,15 @@ public final class ChatFeedModel: ObservableObject {
// line keeps the actor's attribution; a bot bubble wears the
// app's title. Folding via foldDecrypted also mirrors it into
// the store, so a reopen renders it without re-querying.
+ var text = line.text
+ if group, line.action {
+ let you = item.ev.uid == self.myUserId && !self.myUserId.isEmpty
+ text = "\(you ? "You" : (item.ev.name.isEmpty ? "Someone" : item.ev.name)) \(text)"
+ }
let p = MessagePayload(type: "chat",
uid: line.action ? item.ev.uid : nil,
name: line.action ? item.ev.name : line.title,
- text: line.text,
+ text: text,
ts: item.ev.ts.map { Int64($0) },
action: line.action,
attachments: nil,
@@ -1494,15 +1502,25 @@ public final class ChatFeedModel: ObservableObject {
// this is that projection natively: decode the richer event and synthesize
// a renderable payload (bot bubble or action line).
nonisolated public static func renderable(_ blob: String, key: GroupKey,
- group: Bool = false) -> MessagePayload? {
+ group: Bool = false,
+ myUserId: String = "") -> MessagePayload? {
guard let p = try? key.decryptJSON(MessagePayload.self, blob: blob) else { return nil }
if p.isRenderableChat { return p }
guard let ev = try? key.decryptJSON(AppChatProjection.AppEvent.self, blob: blob),
let proj = AppChatProjection.project(ev, group: group) else { return nil }
+ // A GROUP action line wears its actor in the text itself ("You added
+ // “buy milk” to the to-dos") — the web's foldGroupAppEventBubble
+ // prefix, mirrored, so the centred native action row (which renders
+ // text alone) says who acted. Personal threads keep the terse line.
+ var text = proj.text
+ if group, proj.action {
+ let you = !myUserId.isEmpty && p.uid == myUserId
+ text = "\(you ? "You" : (p.name ?? "Someone")) \(text)"
+ }
return MessagePayload(type: "chat",
uid: proj.action ? p.uid : nil,
name: proj.action ? p.name : AppChatProjection.title(proj.appID),
- text: proj.text,
+ text: text,
ts: p.ts,
action: proj.action,
attachments: nil,
diff --git a/ios/LChatCore/Sources/LChatCore/CommandHost.swift b/ios/LChatCore/Sources/LChatCore/CommandHost.swift
index 3e9b0bc..43fc3f3 100644
--- a/ios/LChatCore/Sources/LChatCore/CommandHost.swift
+++ b/ios/LChatCore/Sources/LChatCore/CommandHost.swift
@@ -174,6 +174,31 @@ public final class CommandHost: NSObject, WKScriptMessageHandler {
}
}
+ /// The sidebar line for one decrypted app event (docs/native-apps.md
+ /// voice surfaces) — the app's own preview hook via
+ /// window.lchatNative.previewEvent, defaulting in the engine to the
+ /// actor-prefixed chat projection. nil = leave the fast-path line.
+ public func previewEvent(_ eventJSON: String, you: Bool) async -> String? {
+ for _ in 0..<20 where !ready {
+ try? await Task.sleep(nanoseconds: 400_000_000)
+ }
+ guard ready,
+ let data = try? JSONSerialization.data(withJSONObject: [eventJSON]),
+ let arg = String(data: data, encoding: .utf8) else { return nil }
+ let js = """
+ (function(a){ var n = window.lchatNative;
+ if (!n || !n.previewEvent) return null;
+ try { var r = n.previewEvent(JSON.parse(a[0]), \(you ? "true" : "false"));
+ return r && r.text ? r.text : null; }
+ catch (e) { return null; } })(\(arg))
+ """
+ return await withCheckedContinuation { cont in
+ web.evaluateJavaScript(js) { result, _ in
+ cont.resume(returning: (result as? String).flatMap { $0.isEmpty ? nil : $0 })
+ }
+ }
+ }
+
public func dismissPanel() {
web.evaluateJavaScript(
"window.lchatNative && window.lchatNative.dismissPanel && window.lchatNative.dismissPanel()",
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 81d6983..cf96725 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -36,6 +36,43 @@ public final class ThreadStore: ObservableObject {
private var connectedFetchedAt = Date.distantPast
private var catalogHostKicked = false
+ // The app's own preview hook, answered through the app host (the voice
+ // surfaces' override): cached per (thread, last_at) so refresh churn
+ // never re-queries an unchanged row.
+ private var previewCache: [String: (at: Int64, text: String)] = [:]
+
+ private func fillPreviews(_ queries: [PreviewQuery]) {
+ // Cached answers apply immediately; only genuinely new last-events
+ // ride to the host.
+ var pending: [PreviewQuery] = []
+ for q in queries {
+ if let hit = previewCache[q.threadID], hit.at == q.at {
+ applyPreview(q.threadID, at: q.at, text: hit.text)
+ } else {
+ pending.append(q)
+ }
+ }
+ guard !pending.isEmpty else { return }
+ Task { @MainActor [weak self] in
+ guard let self,
+ let t = self.threads.first(where: { !$0.threadKeyB64.isEmpty }) else { return }
+ let host = CommandHostCache.shared.host(for: self.instance, thread: t,
+ myUserId: self.myUserId,
+ instanceKeyB64: self.instanceKeyB64)
+ for q in pending {
+ guard let text = await host.previewEvent(q.eventJSON, you: q.you) else { continue }
+ self.previewCache[q.threadID] = (q.at, text)
+ self.applyPreview(q.threadID, at: q.at, text: text)
+ }
+ }
+ }
+
+ private func applyPreview(_ threadID: String, at: Int64, text: String) {
+ guard let i = threads.firstIndex(where: { $0.id == threadID }),
+ threads[i].lastAt == at, threads[i].preview != text else { return }
+ threads[i].preview = text
+ }
+
/// An apps_changed frame reached the sidebar socket (someone deployed or
/// changed a custom/member app): invalidate the catalogue and re-pull
/// through a command host — minting one if no page is alive to answer.
@@ -340,6 +377,7 @@ public final class ThreadStore: ObservableObject {
ThreadListSnapshot.save(origin: origin, name: name, rows: rows)
}
}
+ fillPreviews(folded.previewQueries)
// Space-wide installs: one full pass once the instance key is known —
// a cheap no-op on every later refresh (space_thread prods and the
// space thread's live frames carry changes from then on).
@@ -392,6 +430,16 @@ public final class ThreadStore: ObservableObject {
// Raw server read watermarks by thread — merged into serverReadMarks
// on the main actor (the fold runs detached and can't touch it).
var readMarks: [String: Int64]
+ // Threads whose last event is an app event: the app's preview hook
+ // gets the final say, answered async by the app host.
+ var previewQueries: [PreviewQuery]
+ }
+
+ struct PreviewQuery: Sendable {
+ let threadID: String
+ let at: Int64
+ let eventJSON: String
+ let you: Bool
}
nonisolated private static func fold(instance: SavedInstance, myUserId: String,
@@ -490,6 +538,7 @@ public final class ThreadStore: ObservableObject {
var rows: [ChatThread] = []
var readMarks: [String: Int64] = [:]
+ var previewQueries: [PreviewQuery] = []
for t in serverThreads {
// Two key sources: the normal per-member ECIES wrap, or — for an
// open community group joined self-serve, which has no per-member
@@ -560,7 +609,6 @@ public final class ThreadStore: ObservableObject {
var lastMine = false
if let lb = t.last_blob, !lb.isEmpty, let key,
let p = try? key.decryptJSON(MessagePayload.self, blob: lb) {
- preview = previewText(kind: t.kind, payload: p)
// A chat whose latest message is ours isn't unread — sending from
// any device means we've read it. Old payloads have no uid; fall
// back to the screen name (mirrors ownMessage in web/static/ui.js).
@@ -569,6 +617,32 @@ public final class ThreadStore: ObservableObject {
} else if let n = p.name, !n.isEmpty {
lastMine = n == rosterLabels[myUserId]
}
+ if p.isRenderableChat || p.isReactionEvent {
+ preview = previewText(kind: t.kind, payload: p)
+ } else {
+ // An APP EVENT (docs/native-apps.md voice surfaces): the
+ // raw payload text is app data, not a sidebar line ("buy
+ // milk" isn't a preview; "James added “buy milk” to the
+ // to-dos" is). Fast path: the chat projection, actor-
+ // prefixed like the web's default chain; the app's own
+ // preview hook answers async through the app host
+ // (fillPreviews) and overrides this.
+ if let proj = ChatFeedModel.renderable(lb, key: key,
+ group: t.kind == "group",
+ myUserId: myUserId) {
+ let text = proj.text ?? ""
+ if proj.action == true, t.kind != "group" {
+ preview = "\(lastMine ? "You" : (p.name ?? "Someone")) \(text)"
+ } else {
+ preview = text // group action lines carry the actor already
+ }
+ }
+ if let data = try? key.decryptData(blob: lb),
+ let json = String(data: data, encoding: .utf8) {
+ previewQueries.append(PreviewQuery(threadID: t.id, at: t.last_at ?? 0,
+ eventJSON: json, you: lastMine))
+ }
+ }
}
// An app thread with no activity yet shows its blurb instead of a
// blank, vertically-centred title (the row layout top-aligns the
@@ -674,7 +748,7 @@ public final class ThreadStore: ObservableObject {
return Folded(instanceKeyB64: instanceKeyB64, tierKeysB64: tierKeysB64, people: people,
rosterLabels: rosterLabels, nicknames: nicknames,
displayLabels: displayLabels, mutedPeople: mutedPeople,
- rows: rows, readMarks: readMarks)
+ rows: rows, readMarks: readMarks, previewQueries: previewQueries)
}
// MARK: - Per-viewer names (#577)
diff --git a/ios/LChatCore/Tests/LChatCoreTests/Fixtures/schema-vectors-todo.json b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/schema-vectors-todo.json
index a8422a8..9aa643f 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/Fixtures/schema-vectors-todo.json
+++ b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/schema-vectors-todo.json
@@ -25,7 +25,11 @@
]
},
"expected_chat_projection": {
- "text": "added \"buy milk\"",
+ "text": "added “buy milk”",
+ "action": true
+ },
+ "expected_group_chat_projection": {
+ "text": "added “buy milk” to the to-dos",
"action": true
}
},
diff --git a/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
index b541763..a1769e1 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
@@ -95,6 +95,22 @@ final class GroupAppsVectorTests: XCTestCase {
XCTAssertEqual(anonymous?.text, "added an app to this chat")
}
+ // Group lines say where the item went — todo.js/remind.js chatMessage's
+ // opts.group branch, mirrored word for word (the group vector in
+ // test/event-vectors/todo.json pins the web side).
+ func testGroupWordingSaysWhere() {
+ let add = AppChatProjection.AppEvent(type: "todo.add", text: "buy milk")
+ XCTAssertEqual(AppChatProjection.project(add)?.text, "added “buy milk”")
+ XCTAssertEqual(AppChatProjection.project(add, group: true)?.text,
+ "added “buy milk” to the to-dos")
+ var remind = AppChatProjection.AppEvent(type: "remind.add", text: "standup")
+ remind.when = 1_700_000_000_000
+ let personal = AppChatProjection.project(remind)?.text ?? ""
+ let grouped = AppChatProjection.project(remind, group: true)?.text ?? ""
+ XCTAssertTrue(personal.hasPrefix("set a reminder: “standup” — "), personal)
+ XCTAssertTrue(grouped.hasPrefix("set a reminder: “standup” for "), grouped)
+ }
+
// A shared Reminders' fire speaks to everyone — remind.js chatMessage's
// opts.group branch ("you asked me" would be wrong when another member set
// it), threaded natively as project(_:group:).
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index f3e7834..f1f21d8 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -1751,11 +1751,17 @@ struct NativeChatView: View {
// customise further via their events' notice field, which the
// projections already read (remind.fire).
var body = p
+ var actorBaked = false
if !p.isRenderableChat {
guard let proj = ChatFeedModel.renderable(blob, key: key,
- group: thread.kind == "group")
+ group: thread.kind == "group",
+ myUserId: myUserId)
else { return }
body = proj
+ // Group action lines carry their actor in the text now
+ // ("James added …") — prefixing the sender again would
+ // stutter ("James James added …").
+ actorBaked = thread.kind == "group" && proj.action == true
}
// The banner text mirrors the iOS extension exactly — and it must
// never be empty: macOS re-lays-out a communication banner with no
@@ -1769,7 +1775,7 @@ struct NativeChatView: View {
// An action line reads as one sentence ("Bob added “buy milk”");
// in a DM the title already names the sender; groups prefix them.
if body.action == true {
- content.body = "\(sender) \(text)"
+ content.body = actorBaked ? text : "\(sender) \(text)"
} else {
content.body = thread.kind == "group" ? "\(sender): \(text)" : text
}
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 86695ea..e5fb73a 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -1276,7 +1276,7 @@ await test("apps opt into sidebar presence: poll previews + marks unread on crea
assert.equal(appUnread({ type: "poll.vote", pollId: "p" }), false);
// To-do opts in for adds (how a group notices shared-list activity); its
// quiet events (done/reopen/remove) stay silent.
- assert.equal(appPreview({ type: "todo.add", id: "a", text: "milk", name: "Ana" }), "✓ Ana added “milk”");
+ assert.equal(appPreview({ type: "todo.add", id: "a", text: "milk", name: "Ana" }), "✓ Ana added “milk” to the to-dos");
assert.equal(appUnread({ type: "todo.add", id: "a" }), true);
assert.equal(appPreview({ type: "todo.done", id: "a" }), null);
assert.equal(appUnread({ type: "todo.done", id: "a" }), false);
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index 3666e79..066f219 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -373,6 +373,26 @@ allVectors.chat.forEach((vector) => {
}
});
+// The GROUP chat projections are cross-platform spec (AppChatProjection
+// mirrors the wording natively): run the REAL app's chatMessage against any
+// vector that declares one — the fold tests above use local handlers, but
+// wording must come from the shipping module.
+const { app: realTodoApp } = await import("../web/static/apps/todo.js");
+allVectors.todo.forEach((vector) => {
+ if (!("expected_group_chat_projection" in vector)) return;
+ test(`todo group projection: ${vector.name}`, () => {
+ const line = realTodoApp.chatMessage(vector.event, { group: true });
+ const expected = vector.expected_group_chat_projection;
+ if (expected === null) {
+ assert.strictEqual(line, null);
+ return;
+ }
+ const got = typeof line === "string" ? { text: line, action: false }
+ : { text: line.text, action: !!line.action };
+ assert.deepStrictEqual(got, expected);
+ });
+});
+
// Test todo vectors
allVectors.todo.forEach((vector) => {
if (vector.name === "todo_add") {
diff --git a/test/event-vectors/todo.json b/test/event-vectors/todo.json
index a8422a8..9aa643f 100644
--- a/test/event-vectors/todo.json
+++ b/test/event-vectors/todo.json
@@ -25,7 +25,11 @@
]
},
"expected_chat_projection": {
- "text": "added \"buy milk\"",
+ "text": "added “buy milk”",
+ "action": true
+ },
+ "expected_group_chat_projection": {
+ "text": "added “buy milk” to the to-dos",
"action": true
}
},
diff --git a/web/static/apps.js b/web/static/apps.js
index 08a0683..eb0c442 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -2417,11 +2417,29 @@ export function messageActionsFor(msg) {
// the sidebar — so an app opts IN to presence (e.g. poll shows "📊 <question>"
// and marks unread when someone starts a poll). This removes the host's
// hardcoded "app events never preview/unread" special case: the app decides.
-export function appPreview(event) {
+export function appPreview(event, opts) {
const app = typeOwner.get(event && event.type);
- if (!app || !app.preview) return null;
+ if (!app) return null;
+ const you = !!(opts && opts.you);
+ if (app.preview) {
+ try {
+ return app.preview(event, { you }) || null;
+ } catch {
+ return null;
+ }
+ }
+ // The voice defaulting chain (docs/native-apps.md): no preview hook → the
+ // chat projection speaks for the sidebar too, actor-prefixed the way the
+ // feed prefixes action lines. Silent projections stay silent.
try {
- return app.preview(event) || null;
+ let line = null;
+ if (typeof app.groupFeedMessage === "function") line = app.groupFeedMessage(event);
+ else if (typeof app.chatMessage === "function") line = app.chatMessage(event, { group: true });
+ if (!line) return null;
+ if (typeof line === "string") return line;
+ if (typeof line.text !== "string" || !line.text) return null;
+ if (!line.action) return line.text;
+ return `${you ? "You" : event.name || "Someone"} ${line.text}`;
} catch {
return null;
}
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index 45e94dd..c57d5a6 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -1154,3 +1154,25 @@ Keep any non-trivial parsing pure and exported (like `parseReminder` in
(`ios/LChatCore/.../SlashCommands.swift`) so the native apps forward it.
- [ ] A user-facing changelog entry in `changelog/<date>-<slug>.md`.
- [ ] No trailing whitespace; user content rendered with `textContent`.
+
+## 🤖 Voice: how one event speaks on every surface
+
+An app's events can surface in three places, each with its own hook — and a
+defaulting chain so declaring less still reads well everywhere:
+
+1. **The chat feed** — `chatMessage(e, opts)` / `groupFeedMessage(e)`.
+ Action lines are actor-neutral (`added “X” to the to-dos`); the HOST
+ prefixes the actor ("You" for the reader's own act, their name
+ otherwise) — never the app. Group lines should say where the data went
+ ("to the to-dos"); the personal thread IS the list, so it stays terse.
+2. **The thread-list preview** — `preview(e, opts)`, with `opts.you` when
+ the host resolved the actor as the reader. No hook → the chat
+ projection speaks for the sidebar too, actor-prefixed.
+3. **Notifications** — the event's `notice: {title, body}` when set;
+ otherwise the same projection chain as the preview. State-only events
+ (a whiteboard stroke) project null and stay silent everywhere.
+
+Native clients honour the same chain: schema apps project natively
+(AppChatProjection mirrors the wording — change both sides + the vectors
+together), everything else answers through the app host
+(`projectEvent`/`previewEvent`, docs/native-apps.md).
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index 93e0b15..1845373 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -224,14 +224,22 @@ export const app = defineApp({
}
if (e.type === N.ev("add")) {
const at = absTime(e.when);
- return { text: `set a reminder: “${e.text}”${at ? " — " + at : ""}`, action: true };
+ // Group wording says when in prose ("for 9:00") — the line lands among
+ // unrelated conversation; the personal thread keeps the terse dash.
+ const when = at ? (opts && opts.group ? ` for ${at}` : ` — ${at}`) : "";
+ return { text: `set a reminder: “${e.text}”${when}`, action: true };
}
return null;
},
- preview(e) {
+ preview(e, opts) {
// The reminder text rides in the scheduled event's encrypted notice.
if (e.type === N.ev("fire")) return "⏰ " + ((e.notice && e.notice.body) || "Reminder fired");
+ if (e.type === N.ev("add") && e.text) {
+ const actor = opts && opts.you ? "You" : e.name || "Someone";
+ const at = absTime(e.when);
+ return `⏰ ${actor} set a reminder: “${e.text}”${at ? " for " + at : ""}`;
+ }
return null;
},
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index 6e4c27f..75273e3 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -67,8 +67,14 @@ export const app = defineApp({
// Adding a to-do reads as an action in the chat history (a /me-style line);
// done/reopen/remove/move happen in the Interface view and carry no text, so
// they don't clutter the chat. In a group the host prefixes the actor's name.
- chatMessage(e) {
- if (e.type === N.ev("add") && e.text) return { text: `added “${e.text}”`, action: true };
+ chatMessage(e, opts) {
+ // In a GROUP the line lands among unrelated conversation, so it says
+ // where the item went ("to the to-dos"); the personal thread IS the
+ // list, so the shorter line stands. The host prefixes the actor
+ // ("You"/their name) on action lines — never the app.
+ if (e.type === N.ev("add") && e.text) {
+ return { text: opts && opts.group ? `added “${e.text}” to the to-dos` : `added “${e.text}”`, action: true };
+ }
return null;
},
@@ -76,8 +82,13 @@ export const app = defineApp({
// unread dot — how a group notices shared-list activity without opening the
// thread. Your own adds never light the dot (the host's ownMessage gate), and
// ticks/removals/moves stay quiet everywhere.
- preview(e) {
- if (e.type === N.ev("add") && e.text) return `✓ ${e.name || "Someone"} added “${e.text}”`;
+ preview(e, opts) {
+ // opts.you: the host resolved the actor as the reader (docs/native-apps
+ // voice contract) — sidebar lines read "You added…" for your own act.
+ if (e.type === N.ev("add") && e.text) {
+ const actor = opts && opts.you ? "You" : e.name || "Someone";
+ return `✓ ${actor} added “${e.text}” to the to-dos`;
+ }
return null;
},
unread: (e) => e.type === N.ev("add"),
diff --git a/web/static/shell.js b/web/static/shell.js
index 5e589b6..36e11c4 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1652,6 +1652,19 @@ function installNativeBridge() {
// choice, an unknown type, or a member app (its hook runs only in the
// sandbox, never here). `title` rides along so native names the bubble
// without re-deriving it.
+ // The app-host protocol, voice surfaces: the sidebar line for one app
+ // event — the app's own preview hook (opts.you = the actor is the
+ // reader), defaulting to the actor-prefixed chat projection (appPreview's
+ // chain). null = leave the row's line alone.
+ previewEvent(event, you) {
+ try {
+ if (!event || typeof event.type !== "string") return null;
+ const text = appPreview(event, { you: !!you });
+ return text ? { text } : null;
+ } catch {
+ return null;
+ }
+ },
projectEvent(event, group) {
try {
if (!event || typeof event.type !== "string") return null;
@@ -2305,7 +2318,7 @@ async function applyThreads(r) {
// instead (same as the live path below), leaving the row's stale
// preview in place when the app declares none (silent, not blank).
if (isAppType(payload.type)) {
- const pv = appPreview(payload);
+ const pv = appPreview(payload, { you: payload.uid && payload.uid === myUserId });
if (pv) threadPreview[t.id] = pv;
} else if (payload.type !== "reaction") {
// A trailing reaction event stays silent (like the live path) —
@@ -7616,7 +7629,7 @@ function connectWS() {
foldEvent(tid, payload, { id: msg.id, ts: payload.ts });
const owner = appForType(payload.type);
if (owner && owner.name !== "installs") {
- const pv = appPreview(payload);
+ const pv = appPreview(payload, { you: payload.uid && payload.uid === myUserId });
if (pv) spaceAppPreview[owner.name] = pv;
const seeing = activeId === spaceThreadId && spaceAppOpen === owner.name;
if (!seeing && appUnread(payload) && !ownMessage(payload, myUserId, session.name)) spaceAppUnread[owner.name] = true;
@@ -7638,7 +7651,7 @@ function connectWS() {
}
}
notifyLive(tid, payload);
- const pv = appPreview(payload);
+ const pv = appPreview(payload, { you: payload.uid && payload.uid === myUserId });
if (pv) threadPreview[tid] = pv;
if (tid !== activeId && appUnread(payload) && !ownMessage(payload, myUserId, session.name)) {
unread[tid] = true;