eleven / messenger Public

πŸ€– Native apps phase 5: projection queries (lands #1275) (#1317)

* native+web: projection queries β€” feeds narrate any app's events (app-host phase 5)

Phase 5 of docs/native-apps.md. Native feeds no longer silently drop app
events they have no built-in projection for: the app host answers with the
app's own chat line.

- window.lchatNative.projectEvent(event, group) on the embed bridge: the
  web's chatMessage/groupFeedMessage, answered by the engine that owns
  them. Ownership resolves via appForType (never a native prefix guess β€”
  webhooks.* belongs to webhook-monitor); the owning app's title rides
  along so native names the bubble without re-deriving it. The event is
  the decrypted payload β€” native already holds the thread key; no key
  material rides the query. Group feeds prefer groupFeedMessage, else
  chatMessage({group:true}), mirroring foldGroupAppEventBubble.
- ChatFeedModel: renderable() stays the sync, vector-pinned FAST PATH
  (schema apps + installs). Events it can't answer stash during the fold
  (AppChatProjection.unprojected β€” namespaced types only; chat.* internals
  and malformed blobs stay silent) and fill in async: the thread's command
  host projects each, and answered lines fold in as the same synthesized
  payloads renderable() builds (action lines keep the actor's attribution,
  bot bubbles wear the app's title), mirrored to the store so a reopen
  renders them without re-querying.
- Both platforms install the projector lazily (CommandHostCache) β€” the
  host webview only spins up when a thread actually contains such an
  event. Open feeds only: NSE/background sync keep the fast path.
- Member apps stay silent natively (their chatMessage runs only inside
  the /app-sandbox iframe, never in the host page) β€” unchanged, noted.

swift test: 366 pass (4 new unprojected-decode pins); Mac + iOS build;
node --check shell.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: app-event banners speak the projection β€” and state-only events go silent

Field feedback (James, 2026-07-23): a whiteboard stroke bannered
"Bob β€” New message" and a todo add bannered the raw task text. The Mac's
local notifyIfBackgrounded decrypted the payload and showed whatever text
it carried β€” the one banner path that never consulted the projections.

Now app events run through the same renderable() the iOS extension uses:
no projection (board strokes, todo re-orders β€” state-only) β†’ NO banner;
a projected event speaks the app's own line, action lines reading as one
sentence ("Bob added "buy milk""). Apps customise copy via their events'
notice field, which the projections already read (remind.fire).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
James Adam f7700a49ab170dad0640494f727d9850a4aca095 parent 499640f
10 files changed, +304 −14
  • docs/native-apps.md +9 −2
  • ios/LChat/Views/NativeChatScreen.swift +11 −0
  • ios/LChatCore/Package.resolved +9 −0
  • ios/LChatCore/Sources/LChatCore/AppChatProjection.swift +43 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +78 −8
  • ios/LChatCore/Sources/LChatCore/CommandHost.swift +36 −0
  • ios/LChatCore/Tests/LChatCoreTests/ProjectionQueryTests.swift +44 −0
  • ios/LChatMac/NativeChatView.swift +23 −4
  • ios/LChatMac/NativeFeedView.swift +15 −0
  • web/static/shell.js +36 −0
diff --git a/docs/native-apps.md b/docs/native-apps.md
index da24aba..858614c 100644
--- a/docs/native-apps.md
+++ b/docs/native-apps.md
@@ -1,6 +1,13 @@
# πŸ€– Native apps Γ— the app platform: apps as first-class citizens
-> Status: **DESIGN + PHASES 0–4 (2026-07-22)** β€” phase 4 (branch
+> Status: **DESIGN + PHASES 0–5 (2026-07-22)** β€” phase 5 (branch
+> `claude/native-apps-projection`) ships projection queries: app events
+> AppChatProjection can't answer (the sync, vector-pinned fast path for
+> schema apps + installs) stash during the fold and query the thread's
+> command host (`projectEvent` β€” the web's own chatMessage/groupFeedMessage),
+> folding the answered lines in as the same synthesized bubbles. Open feeds
+> only (NSE/background sync keep the fast path); member apps stay silent
+> natively (their hooks run only in the sandbox). Phase 4 (branch
> `claude/native-apps-space`) ships space routing + the space band: the
> embed space-thread bail lifts when the native shell seeded the instance
> key (space-installed apps' commands β€” /gif β€” route in the command host),
@@ -225,7 +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 | `projectEvent(app, eventJSON, group)` β†’ `{text, action?} \| 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 | phase 5 |
+| 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
is the command engine, so its reach is the union of the member's app threads,
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index db5299e..9d54329 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -46,6 +46,17 @@ struct NativeChatScreen: View {
}
// Reopen on the view this group was last showing (chat or a shared app).
_groupAppID = State(initialValue: GroupAppMemory.selection[thread.id])
+ // The app-host projection queries (docs/native-apps.md phase 5): the
+ // model asks the thread's command host to project app events it has
+ // no built-in projection for. Lazy β€” the host webview only spins up
+ // when a thread actually contains such an event.
+ let projInstance = instance, projThread = thread
+ let projUserId = store.myUserId, projIK = store.instanceKey()
+ model.projector = { eventJSON, group in
+ let host = CommandHostCache.shared.host(for: projInstance, thread: projThread,
+ myUserId: projUserId, instanceKeyB64: projIK)
+ return await host.projectEvent(eventJSON, group: group)
+ }
}
// The pre-tap last-seen time; non-nil = this open should anchor unreads.
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
index d82f91f..fe25d1c 100644
--- a/ios/LChatCore/Package.resolved
+++ b/ios/LChatCore/Package.resolved
@@ -1,5 +1,14 @@
{
"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 930f3e0..7dca536 100644
--- a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
@@ -31,6 +31,49 @@ public enum AppChatProjection {
["todo": "To-do", "remind": "Reminders"][appID] ?? appID
}
+ /// One projected chat line answered by the APP HOST (docs/native-apps.md
+ /// phase 5 β€” window.lchatNative.projectEvent): the web's chatMessage /
+ /// groupFeedMessage output plus the owning app's title, so native builds
+ /// the same bubble renderable() synthesizes without re-deriving anything.
+ public struct ProjectedLine: Sendable, Equatable {
+ public let text: String
+ public let action: Bool
+ public let title: String
+ public init(text: String, action: Bool, title: String) {
+ self.text = text
+ self.action = action
+ self.title = title
+ }
+ }
+
+ /// One app event native has NO built-in projection for β€” the raw material
+ /// of a projectEvent query. Decoded when renderable() drew a blank: any
+ /// namespaced event type ("myhabits.checkin") plus the envelope fields an
+ /// answered line needs (attribution + timestamp) and the decrypted JSON
+ /// itself, handed verbatim to the host (native already holds the thread
+ /// key β€” no key material rides the query).
+ public struct UnprojectedAppEvent: Sendable, Equatable {
+ public let eventJSON: String
+ public let name: String
+ public let uid: String?
+ public let ts: Double?
+ }
+
+ nonisolated public static func unprojected(_ blob: String, key: GroupKey) -> UnprojectedAppEvent? {
+ guard let data = try? key.decryptData(blob: blob),
+ let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
+ let type = obj["type"] as? String,
+ let dot = type.firstIndex(of: "."), dot != type.startIndex,
+ let json = String(data: data, encoding: .utf8) else { return nil }
+ // Only namespaced app events query β€” chat.* internals (typing beats)
+ // and anything unnamespaced stay silent, exactly as they fold today.
+ if type.hasPrefix("chat.") { return nil }
+ return UnprojectedAppEvent(eventJSON: json,
+ name: obj["name"] as? String ?? "",
+ uid: obj["uid"] as? String,
+ ts: (obj["ts"] as? NSNumber)?.doubleValue)
+ }
+
/// Project one event into a chat line; nil = this event doesn't render
/// (state-only events like todo.toggle, mirroring chatMessage's nulls).
/// `group` = the event lives in a GROUP thread (chatMessage's opts.group):
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index b6dff93..3373d3a 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -1051,13 +1051,16 @@ public final class ChatFeedModel: ObservableObject {
let screenName = self.instance.screenName
let wantGroupApps = threadKind == "group"
let foldSpan = Perf.begin("feed.foldPage", slowMs: 250)
- let (fresh, stored, appEvents, reactionRows): ([Message], [MessageStore.StoredMessage],
- [(Int64, GroupApps.Event)], [MessagePayload]) =
+ let (fresh, stored, appEvents, reactionRows, unprojectedRows):
+ ([Message], [MessageStore.StoredMessage],
+ [(Int64, GroupApps.Event)], [MessagePayload],
+ [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)]) =
await Task.detached(priority: .userInitiated) {
var out: [Message] = []
var toStore: [MessageStore.StoredMessage] = []
var events: [(Int64, GroupApps.Event)] = []
var reacts: [MessagePayload] = []
+ var unproj: [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)] = []
for row in rows {
if (row.tombstone_of ?? 0) > 0 { continue } // evicted above; empty blob, nothing to fold
if wantGroupApps, let e = Self.groupAppsEvent(row.blob, key: key) {
@@ -1072,7 +1075,16 @@ public final class ChatFeedModel: ObservableObject {
continue
}
guard let p = Self.renderable(row.blob, key: key, group: wantGroupApps)
- else { continue }
+ else {
+ // An app event with no built-in projection: stash it for
+ // the app-host query (docs/native-apps.md phase 5) β€” the
+ // web engine answers with the app's own chat line, or
+ // null (silent, exactly as it folds today).
+ if let ev = AppChatProjection.unprojected(row.blob, key: key) {
+ unproj.append((row.id, row.created_at, ev))
+ }
+ continue
+ }
toStore.append(.init(id: row.id, at: row.created_at,
edited: (row.edited_at ?? 0) > 0,
reactions: [], payload: p))
@@ -1086,7 +1098,7 @@ public final class ChatFeedModel: ObservableObject {
attachments: p.attachments ?? [], reply: p.replyTo,
reactions: [], raw: p))
}
- return (out, toStore, events, reacts)
+ return (out, toStore, events, reacts, unproj)
}.value
foldSpan.end("\(rows.count) rows")
// Tamper-evidence (history): compare gossip from every message + reaction
@@ -1147,6 +1159,7 @@ public final class ChatFeedModel: ObservableObject {
list.sort { $0.id < $1.id }
Self.refreshReplyCounts(&list)
messages = list
+ noteUnprojected(unprojectedRows)
}
private func apply(_ frame: ChatSocket.Frame) {
@@ -1170,16 +1183,19 @@ public final class ChatFeedModel: ObservableObject {
p.isReactionEvent {
return .reaction(p)
}
- return .message(Self.renderable(blob, key: key, group: wantGroupApps),
- wantGroupApps ? Self.groupAppsEvent(blob, key: key) : nil)
+ let payload = Self.renderable(blob, key: key, group: wantGroupApps)
+ return .message(payload,
+ wantGroupApps ? Self.groupAppsEvent(blob, key: key) : nil,
+ payload == nil ? AppChatProjection.unprojected(blob, key: key) : nil)
}) { model, inbound in
switch inbound {
case .reaction(let p):
model.applyReactionEvent(p)
- case .message(let payload, let groupApps):
+ case .message(let payload, let groupApps, let unprojected):
model.foldDecrypted(id: id, at: at, payload: payload,
edited: false, reactions: [])
if let e = groupApps { model.foldGroupAppsEvent(id: id, e) }
+ if let ev = unprojected { model.noteUnprojected([(id, at, ev)]) }
}
}
case "typing":
@@ -1232,7 +1248,7 @@ public final class ChatFeedModel: ObservableObject {
// possibly carrying a group-apps event alongside.
private enum InboundStored: Sendable {
case reaction(MessagePayload)
- case message(MessagePayload?, GroupApps.Event?)
+ case message(MessagePayload?, GroupApps.Event?, AppChatProjection.UnprojectedAppEvent?)
}
// Fold one reaction event into a message and re-derive its rendered tally
@@ -1419,6 +1435,60 @@ public final class ChatFeedModel: ObservableObject {
// history refetch and the live echo of an already-seen message must not
// duplicate). Batch callers fold a whole page before publishing once.
+ // ---- app-host projection queries (docs/native-apps.md phase 5) --------
+ // AppChatProjection is the FAST PATH (schema apps + installs, sync, no
+ // flash); every other app event queries the app host β€” the web engine
+ // that owns chatMessage/groupFeedMessage β€” and its answer folds in as the
+ // same synthesized bubble renderable() builds. The platform feed views
+ // install the projector (a CommandHost call); without one, unknown
+ // events stay silent exactly as before.
+
+ /// Set by the platform feed view: project one decrypted event JSON
+ /// (group = a group feed) into its chat line, or nil for silent.
+ public var projector: (@MainActor (String, Bool) async -> AppChatProjection.ProjectedLine?)? {
+ didSet { fillProjections() }
+ }
+ private var pendingProjection: [Int64: (at: Int64, ev: AppChatProjection.UnprojectedAppEvent)] = [:]
+ private var projectionAsked: Set<Int64> = []
+ private var projectionTask: Task<Void, Never>?
+
+ private func noteUnprojected(_ batch: [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)]) {
+ var added = false
+ for (id, at, ev) in batch where !projectionAsked.contains(id) {
+ pendingProjection[id] = (at, ev)
+ added = true
+ }
+ if added { fillProjections() }
+ }
+
+ private func fillProjections() {
+ guard projectionTask == nil, projector != nil, !pendingProjection.isEmpty else { return }
+ projectionTask = Task { @MainActor [weak self] in
+ defer { self?.projectionTask = nil }
+ let group = self?.threadKind == "group"
+ while let self, let projector = self.projector,
+ let (id, item) = self.pendingProjection.min(by: { $0.key < $1.key }) {
+ self.pendingProjection.removeValue(forKey: id)
+ self.projectionAsked.insert(id)
+ guard let line = await projector(item.ev.eventJSON, group),
+ !line.text.isEmpty else { continue }
+ // The same synthesized payload renderable() builds: an action
+ // 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.
+ let p = MessagePayload(type: "chat",
+ uid: line.action ? item.ev.uid : nil,
+ name: line.action ? item.ev.name : line.title,
+ text: line.text,
+ ts: item.ev.ts.map { Int64($0) },
+ action: line.action,
+ attachments: nil,
+ replyTo: nil)
+ self.foldDecrypted(id: id, at: item.at, payload: p, edited: false, reactions: [])
+ }
+ }
+ }
+
// App-thread events (todo.add, remind.fire, …) aren't type:"chat" β€” the
// companion apps' Chat view projects them into chat lines on the web, and
// this is that projection natively: decode the richer event and synthesize
diff --git a/ios/LChatCore/Sources/LChatCore/CommandHost.swift b/ios/LChatCore/Sources/LChatCore/CommandHost.swift
index af1c6ab..3e9b0bc 100644
--- a/ios/LChatCore/Sources/LChatCore/CommandHost.swift
+++ b/ios/LChatCore/Sources/LChatCore/CommandHost.swift
@@ -138,6 +138,42 @@ public final class CommandHost: NSObject, WKScriptMessageHandler {
}
}
+ /// Project one decrypted app event into its chat line (docs/native-apps.md
+ /// phase 5) β€” window.lchatNative.projectEvent, the web's chatMessage /
+ /// groupFeedMessage answered by the engine that owns them. The event JSON
+ /// is plaintext native already holds; no key material rides the query.
+ /// Waits briefly for a booting page; nil = silent (or an older server
+ /// without the bridge).
+ public func projectEvent(_ eventJSON: String, group: Bool) async -> AppChatProjection.ProjectedLine? {
+ 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.projectEvent) return null;
+ try { var r = n.projectEvent(JSON.parse(a[0]), \(group ? "true" : "false"));
+ return r ? JSON.stringify(r) : null; }
+ catch (e) { return null; } })(\(arg))
+ """
+ return await withCheckedContinuation { cont in
+ web.evaluateJavaScript(js) { result, _ in
+ guard let s = result as? String,
+ let obj = (try? JSONSerialization.jsonObject(with: Data(s.utf8))) as? [String: Any],
+ let text = obj["text"] as? String, !text.isEmpty else {
+ cont.resume(returning: nil)
+ return
+ }
+ cont.resume(returning: AppChatProjection.ProjectedLine(
+ text: text,
+ action: obj["action"] as? Bool ?? false,
+ title: obj["title"] as? String ?? ""))
+ }
+ }
+ }
+
public func dismissPanel() {
web.evaluateJavaScript(
"window.lchatNative && window.lchatNative.dismissPanel && window.lchatNative.dismissPanel()",
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ProjectionQueryTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ProjectionQueryTests.swift
new file mode 100644
index 0000000..5ef614d
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/ProjectionQueryTests.swift
@@ -0,0 +1,44 @@
+import XCTest
+@testable import LChatCore
+
+// πŸ€– Phase 5 (docs/native-apps.md): the raw material of an app-host
+// projection query β€” AppChatProjection.unprojected decodes exactly the app
+// events native has no built-in projection for, and nothing else. The
+// query's other half (window.lchatNative.projectEvent) lives in shell.js and
+// answers with the app's own chatMessage/groupFeedMessage line.
+final class ProjectionQueryTests: XCTestCase {
+ private let key = try! GroupKey(rawKey: Data(repeating: 3, count: 32))
+
+ private func blob(_ json: String) -> String {
+ try! key.encryptData(Data(json.utf8))
+ }
+
+ func testNamespacedEventDecodes() {
+ let json = #"{"type":"myhabits.checkin","habit":"run","name":"Ana","uid":"u-ana","ts":1700000000000}"#
+ let ev = AppChatProjection.unprojected(blob(json), key: key)
+ XCTAssertEqual(ev?.name, "Ana")
+ XCTAssertEqual(ev?.uid, "u-ana")
+ XCTAssertEqual(ev?.ts, 1_700_000_000_000)
+ // The decrypted JSON rides verbatim β€” the host's projectEvent parses it.
+ XCTAssertEqual(ev?.eventJSON, json)
+ }
+
+ func testEnvelopeFieldsAreOptional() {
+ let ev = AppChatProjection.unprojected(blob(#"{"type":"vault.add"}"#), key: key)
+ XCTAssertEqual(ev?.name, "")
+ XCTAssertNil(ev?.uid)
+ XCTAssertNil(ev?.ts)
+ }
+
+ func testChatInternalsStaySilent() {
+ // chat.* internals (typing beats, checkpoints) never query.
+ XCTAssertNil(AppChatProjection.unprojected(blob(#"{"type":"chat.typing","name":"Ana"}"#), key: key))
+ }
+
+ func testUnnamespacedAndMalformedStaySilent() {
+ XCTAssertNil(AppChatProjection.unprojected(blob(#"{"type":"chat","text":"hi"}"#), key: key))
+ XCTAssertNil(AppChatProjection.unprojected(blob(#"{"type":".oops"}"#), key: key))
+ XCTAssertNil(AppChatProjection.unprojected(blob(#"{"text":"no type"}"#), key: key))
+ XCTAssertNil(AppChatProjection.unprojected("not-a-blob", key: key))
+ }
+}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index aab61a5..f3e7834 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -1743,17 +1743,36 @@ struct NativeChatView: View {
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
return
}
+ // An APP EVENT banners only what a feed would say β€” the same
+ // renderable() projection the iOS extension uses. A whiteboard
+ // stroke or a todo re-order (state-only, no projection) stays
+ // SILENT instead of buzzing "New message"; a projected event
+ // speaks the app's own line ("added β€œbuy milk”"), and apps
+ // customise further via their events' notice field, which the
+ // projections already read (remind.fire).
+ var body = p
+ if !p.isRenderableChat {
+ guard let proj = ChatFeedModel.renderable(blob, key: key,
+ group: thread.kind == "group")
+ else { return }
+ body = proj
+ }
// The banner text mirrors the iOS extension exactly β€” and it must
// never be empty: macOS re-lays-out a communication banner with no
// body as "app name + sender", dropping the message entirely (a
// textless app event rendered as the baffling "Eleven / James").
- var text = markdownPlainText(p.text ?? "")
+ var text = markdownPlainText(body.text ?? "")
if text.isEmpty {
- let n = p.attachments?.count ?? 0
+ let n = body.attachments?.count ?? 0
text = n > 0 ? (n == 1 ? "πŸ“Ž Attachment" : "πŸ“Ž \(n) attachments") : "New message"
}
- // In a DM the title already names the sender; groups prefix them.
- content.body = thread.kind == "group" ? "\(sender): \(text)" : text
+ // 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)"
+ } else {
+ content.body = thread.kind == "group" ? "\(sender): \(text)" : text
+ }
content.sound = .default
content.userInfo = ["instance": instanceID, "thread": tid]
// Sender's face on the banner where possible β€” same restyle the
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index e169d3e..0788f62 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -684,6 +684,7 @@ struct NativeFeedView: View {
frames.isActive = isActive
installSwipeMonitor()
registerForDrops()
+ installProjector()
}
.onChange(of: isActive) { _, active in
frames.isActive = active
@@ -1623,6 +1624,20 @@ struct NativeFeedView: View {
// `sink` at THIS mount every call, like the model-cache re-point pattern,
// so a revisited thread's cached host talks to the pane on screen.
@discardableResult
+ // The app-host projection queries (docs/native-apps.md phase 5): the
+ // model asks the thread's command host to project app events it has no
+ // built-in projection for. Lazy β€” the host webview only spins up when a
+ // thread actually contains such an event (or a command is typed).
+ private func installProjector() {
+ guard let instance else { return }
+ let thread = thread, myUserId = myUserId, ikB64 = instanceKeyB64
+ model.projector = { eventJSON, group in
+ let host = CommandHostCache.shared.host(for: instance, thread: thread,
+ myUserId: myUserId, instanceKeyB64: ikB64)
+ return await host.projectEvent(eventJSON, group: group)
+ }
+ }
+
private func ensureCommandHost() -> CommandHost? {
guard let instance else { return nil }
let host = commandHost ?? CommandHostCache.shared.host(
diff --git a/web/static/shell.js b/web/static/shell.js
index 59fff4d..5e589b6 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1640,6 +1640,42 @@ function installNativeBridge() {
commandItems() {
return embed && embed.threadId !== undefined ? commandCatalog(embed.threadId) : commandCatalog();
},
+ // The app-host protocol, phase 5 (docs/native-apps.md): project ONE app
+ // event into its chat line for the NATIVE feed β€” the web's chatMessage /
+ // groupFeedMessage answered by the engine that owns them. The event is
+ // the decrypted payload (native already holds the thread key; no key
+ // material rides here). Ownership resolves via appForType β€” the same
+ // registry the web folds use, so families like webhooks.* land on their
+ // app. Mirrors foldGroupAppEventBubble/foldAppEventBubble: a group feed
+ // prefers groupFeedMessage, else chatMessage({group:true}); a personal
+ // companion projects through chatMessage. null = silent by the app's own
+ // 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.
+ projectEvent(event, group) {
+ try {
+ if (!event || typeof event.type !== "string") return null;
+ const app = appForType(event.type);
+ if (!app) return null;
+ let line = null;
+ if (group) {
+ if (typeof app.groupFeedMessage === "function") line = app.groupFeedMessage(event);
+ else if (typeof app.chatMessage === "function") line = app.chatMessage(event, { group: true });
+ } else if (typeof app.chatMessage === "function") {
+ line = app.chatMessage(event);
+ }
+ if (!line) return null;
+ const text = typeof line === "string" ? line : typeof line.text === "string" ? line.text : "";
+ if (!text) return null;
+ return {
+ text,
+ action: typeof line === "object" && !!line.action,
+ title: app.title || app.name,
+ };
+ } catch {
+ return null;
+ }
+ },
// Composer typeahead for the native input: same token detection + ranking
// as the web menu (composerToken + typeaheadItems). caret is a UTF-16
// offset, matching JS string indexing. Returns {start, items} or null.