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>
17 files changed,
+273
−36
docs/native-apps.md+1 −0ios/LChatCore/Package.resolved+0 −9ios/LChatCore/Sources/LChatCore/AppChatProjection.swift+9 −3ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+23 −5ios/LChatCore/Sources/LChatCore/CommandHost.swift+25 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+76 −2ios/LChatCore/Tests/LChatCoreTests/Fixtures/schema-vectors-todo.json+5 −1ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift+16 −0ios/LChatMac/NativeChatView.swift+8 −2test/apps.test.mjs+1 −1test/event-vectors.test.mjs+20 −0test/event-vectors/todo.json+5 −1web/static/apps.js+21 −3web/static/apps/README.md+22 −0web/static/apps/remind.js+10 −2web/static/apps/todo.js+15 −4web/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; |