eleven / messenger Public

ios+mac: app sharing in the native apps — group apps visible, New group includes apps, webview loading veil (#479)

* ios+mac: group app sharing — see a group's shared apps natively, pick them at group birth, webview loading veil

Shared core: GroupApps fold (snapshot seed + groupapps.* events, the web's
groupapps registry), ChatFeedModel folds + publishes a group's added apps,
createChat seeds groupapps.add events, GroupAppsSheet manages them.
iOS: NativeChatScreen view picker (Chat | each app) + GroupAppPane webview;
New group gains an Apps section. Mac: Chat | Both | App-dropdown header
switch, group split surface, New-group SHARED APPS section. Both: app panes
veil until the page reports ready (shell.js posts {ready:true}); embed seed
gains app + setGroupApp for the shared pane.

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

* core: review fixes — group-aware remind.fire wording, explicit group intent in createChat

A shared Reminders' fire now reads "Reminder: …" in groups (remind.js's
opts.group branch, threaded as project(_:group:) through every renderable
caller incl. NSE + background sync). createChat gains the web's explicit
group intent so a one-other-person group is a real kind:"group" — its name
and seeded apps stick instead of silently deduping into a DM.

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

* mac+ios: app dropdown drops the checkmark; group pane never sits on chat

The dropdown rows are glyph + name only (To-do's glyph IS a checkmark — an
active-tick column read as every row ticked). setGroupApp retries until the
page bridge is up and falls back to setAppView("interface") on servers whose
shell.js predates embed.app, so the shared pane shows an app, never the chat.

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

* mac: group segments match the companion switch; the app picker is its own menu

Chat | Both | View — the third segment wears the shown app's glyph and zooms
it full-pane, exactly like a companion app-thread. The view selector (each
added app + Manage Apps…) moves to a separate chevron menu beside the group.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f68fb10f9af130e9c036d3665af1d8f97a738886 parent d8425b8
20 files changed, +1218 −49
  • ios/LChat/FeedWebCache.swift +31 −10
  • ios/LChat/Views/ChatView.swift +58 −0
  • ios/LChat/Views/GroupAppPane.swift +65 −0
  • ios/LChat/Views/NativeChatScreen.swift +64 −2
  • ios/LChat/Views/NewChatView.swift +35 −1
  • ios/LChatCore/Sources/LChatCore/AppChatProjection.swift +20 −2
  • ios/LChatCore/Sources/LChatCore/BackgroundSync.swift +2 −1
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +116 −7
  • ios/LChatCore/Sources/LChatCore/Crypto.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/GroupApps.swift +104 −0
  • ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift +72 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +35 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +41 −8
  • ios/LChatCore/Sources/LChatCore/WebLoadingVeil.swift +26 −0
  • ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift +125 −0
  • ios/LChatMac/AppThreadWebView.swift +171 −11
  • ios/LChatMac/NativeChatView.swift +173 −4
  • ios/LChatMac/NewChatPopover.swift +48 −2
  • ios/LChatNSE/NotificationService.swift +1 −1
  • web/static/shell.js +24 −0
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift
index 4cd848d..3cdb446 100644
--- a/ios/LChat/FeedWebCache.swift
+++ b/ios/LChat/FeedWebCache.swift
@@ -49,9 +49,13 @@ final class FeedWebCache {
// The live webview for this thread — reused if we have it, created (and left
// loading) if not. Marks the entry most-recently-used so eviction spares it.
+ // `app` names one of a GROUP's added apps: that pane renders the app's
+ // interface instead of the chat feed, and caches separately from the feed
+ // (same thread, different surface).
func webView(for instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?) -> WKWebView {
- let e = entry(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64)
+ instanceKeyB64: String?, app: String? = nil) -> WKWebView {
+ let e = entry(for: instance, thread: thread, myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app)
e.lastUsed = Date()
return e.web
}
@@ -59,8 +63,9 @@ final class FeedWebCache {
// The bridge that owns this thread's webview. ChatView observes this instead of
// creating its own, so composer state (staged reply/photo) survives with the page.
func bridge(for instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?) -> WebBridge {
- entry(for: instance, thread: thread, myUserId: myUserId, instanceKeyB64: instanceKeyB64).bridge
+ instanceKeyB64: String?, app: String? = nil) -> WebBridge {
+ entry(for: instance, thread: thread, myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app).bridge
}
// Pre-warm feeds for the given threads (the top of the sidebar): create their
@@ -73,14 +78,17 @@ final class FeedWebCache {
}
private func entry(for instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?) -> Entry {
- let key = instance.id + "|" + thread.id
+ instanceKeyB64: String?, app: String? = nil) -> Entry {
+ // One shared pane per group for ALL its apps (switching apps is a JS
+ // call into the same page — WebBridge.setGroupApp — not a reload), so
+ // the key marks the surface, not the app.
+ let key = instance.id + "|" + thread.id + (app != nil ? "|groupapps" : "")
// Reuse only if the page was seeded with the same thread key — a re-invite /
// key change makes the parked page stale, so rebuild it.
if let e = entries[key], e.seedKey == thread.threadKeyB64 { return e }
if let e = entries.removeValue(forKey: key) { release(e) }
let e = makeEntry(instance: instance, thread: thread, myUserId: myUserId,
- instanceKeyB64: instanceKeyB64)
+ instanceKeyB64: instanceKeyB64, app: app)
entries[key] = e
evictIfNeeded()
return e
@@ -88,13 +96,13 @@ final class FeedWebCache {
// The webview construction that used to live in ChatFeedWebView.makeUIView.
private func makeEntry(instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?) -> Entry {
+ instanceKeyB64: String?, app: String? = nil) -> Entry {
let bridge = WebBridge()
let cfg = WKWebViewConfiguration()
cfg.websiteDataStore = .default()
cfg.allowsInlineMediaPlayback = true
if let script = Self.bootScript(instance: instance, thread: thread, myUserId: myUserId,
- instanceKeyB64: instanceKeyB64) {
+ instanceKeyB64: instanceKeyB64, app: app) {
cfg.userContentController.addUserScript(script)
}
cfg.userContentController.add(bridge, name: "lchat")
@@ -123,7 +131,7 @@ final class FeedWebCache {
// the thread key crosses over — never the user private key.
// (AppViewMemory lives below so the seed and ChatView's toggle share one truth.)
private static func bootScript(instance: SavedInstance, thread: ChatThread, myUserId: String,
- instanceKeyB64: String?) -> WKUserScript? {
+ instanceKeyB64: String?, app: String? = nil) -> WKUserScript? {
var seed: [String: Any] = [
"token": instance.deviceToken,
"name": instance.screenName,
@@ -136,6 +144,12 @@ final class FeedWebCache {
// page always start in agreement, parked OR freshly booted.
"view": AppViewMemory.views[thread.id] ?? "chat",
]
+ if let app {
+ // A group's shared app pane: this page is purely that app's
+ // interface — the native screen renders the chat itself.
+ seed["app"] = app
+ seed["view"] = "interface"
+ }
if let ik = instanceKeyB64 { seed["instanceKey"] = ik }
guard let data = try? JSONSerialization.data(withJSONObject: seed),
let json = String(data: data, encoding: .utf8) else { return nil }
@@ -176,3 +190,10 @@ final class FeedWebCache {
enum AppViewMemory {
static var views: [String: String] = [:]
}
+
+// A GROUP chat's chosen view for this session: nil/absent = the conversation,
+// an app id = that added app's interface. The native mirror of the web's
+// groupAppView; session memory only, like AppViewMemory.
+enum GroupAppMemory {
+ static var selection: [String: String] = [:] // thread id -> app id
+}
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 17eb845..7dfa374 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -155,11 +155,19 @@ struct ChatView: View {
}
}
.allowsHitTesting(false)
+ // A booting page is a blank pane — veil it until it has
+ // painted ({ready:true} from shell.js, or the nav fallback).
+ if !bridge.pageReady {
+ WebLoadingVeil()
+ .ignoresSafeArea()
+ .transition(.opacity)
+ }
} else {
ContentUnavailableView("Can't open this chat", systemImage: "exclamationmark.triangle",
description: Text("This chat's address is invalid."))
}
}
+ .animation(.easeOut(duration: 0.25), value: bridge.pageReady)
.safeAreaInset(edge: .bottom, spacing: 0) {
// In the Interface view the app surface owns the whole pane —
// including its own inputs (To-do's add row, Reminders' form) —
@@ -539,6 +547,27 @@ struct ChatView: View {
final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNavigationDelegate, WKUIDelegate {
weak var webView: WKWebView?
+ // True once the page has real content to show. The page posts {ready:true}
+ // after its first render (shell.js bootEmbed) — that's the honest "painted"
+ // moment; didFinish is only a fallback (it fires when the HTML lands, well
+ // before the app has drawn, and never fires again on a parked page). Hosts
+ // overlay a loading veil until this flips so a booting page never shows as
+ // a blank pane.
+ @Published var pageReady = false
+
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
+ pageReady = false
+ }
+
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ pageReady = true // show whatever the page has — a veil that never lifts is worse
+ }
+
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
+ withError error: Error) {
+ pageReady = true
+ }
+
// The native shell's floating bars + keyboard, as CSS pixel insets. The page
// pads the feed with these (--native-top/bottom-inset in style.css) so
// messages scroll under the glass but rest clear of it. Kept and re-applied
@@ -594,6 +623,11 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
applyInsets()
applyKeyboardDismiss()
+ // Fallback for a page that never posts {ready:true} (an older server's
+ // shell.js): give its boot a beat to paint, then lift the veil anyway.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
+ self?.pageReady = true
+ }
}
// MARK: Interactive keyboard dismissal
@@ -688,6 +722,29 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
"window.lchatNative && window.lchatNative.setAppView && window.lchatNative.setAppView('\(view)')")
}
+ // Swap which of a group's added apps the shared pane shows (shell.js
+ // setGroupApp) — no reload, the pane re-renders from folded state. `app`
+ // is a catalog id (ours, never user text — safe to interpolate). The pane
+ // must NEVER sit on the chat view: against a server whose shell.js
+ // predates setGroupApp/embed.app, fall back to setAppView("interface")
+ // (the old bridge routes a group with apps to its app pane). The page's
+ // bridge installs a beat after didFinish, so retry until a call lands.
+ func setGroupApp(_ app: String, attempt: Int = 0) {
+ let js = """
+ (function(){ var n = window.lchatNative;
+ if (!n) return false;
+ if (n.setGroupApp) { n.setGroupApp('\(app)'); return true; }
+ if (n.setAppView) { n.setAppView('interface'); return true; }
+ return false; })()
+ """
+ webView?.evaluateJavaScript(js) { [weak self] result, _ in
+ guard (result as? Bool) != true, attempt < 12 else { return }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ self?.setGroupApp(app, attempt: attempt + 1)
+ }
+ }
+ }
+
// WKUIDelegate shows nothing for window.confirm()/alert() unless we implement
// these — chat.js's "Delete this message" confirm (reachable here since embed mode
// still shares renderApp()/openThread() with the full app) silently no-ops without
@@ -819,6 +876,7 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
// {error: String} when an upload/stage fails. Both arrive on the one "lchat" handler.
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "lchat", let body = message.body as? [String: Any] else { return }
+ if body["ready"] as? Bool == true { pageReady = true }
if let staged = body["staged"] as? Bool { attachmentStaged = staged }
if let err = body["error"] as? String { lastError = err }
if body.keys.contains("replying") {
diff --git a/ios/LChat/Views/GroupAppPane.swift b/ios/LChat/Views/GroupAppPane.swift
new file mode 100644
index 0000000..735ea2e
--- /dev/null
+++ b/ios/LChat/Views/GroupAppPane.swift
@@ -0,0 +1,65 @@
+import LChatCore
+import SwiftUI
+import WebKit
+
+// 🤖 A GROUP chat's shared app surface: the selected added app's interface,
+// rendered by the same embed page ChatView uses — seeded with this thread's
+// key plus the app to show (FeedWebCache, app != nil). ONE cached page serves
+// all the group's apps; switching apps is a JS call into it
+// (WebBridge.setGroupApp), never a reload. NativeChatScreen swaps this in for
+// the native feed when the header's view picker chooses an app.
+struct GroupAppPane: View {
+ @ObservedObject private var bridge: WebBridge
+ let instance: SavedInstance
+ let thread: ChatThread
+ let myUserId: String
+ let instanceKeyB64: String?
+ let app: String
+
+ @MainActor
+ init(instance: SavedInstance, thread: ChatThread, myUserId: String,
+ instanceKeyB64: String?, app: String) {
+ self.instance = instance
+ self.thread = thread
+ self.myUserId = myUserId
+ self.instanceKeyB64 = instanceKeyB64
+ self.app = app
+ bridge = FeedWebCache.shared.bridge(for: instance, thread: thread,
+ myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app)
+ }
+
+ var body: some View {
+ ZStack {
+ GroupAppWebView(instance: instance, thread: thread, myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app)
+ if !bridge.pageReady {
+ WebLoadingVeil().transition(.opacity)
+ }
+ }
+ .animation(.easeOut(duration: 0.25), value: bridge.pageReady)
+ // Re-assert the selection on every appearance/switch. The boot seed
+ // carries the app chosen at page-creation time; a still-booting page
+ // ignores the JS no-op, so the ready flip re-asserts once more.
+ .onAppear { bridge.setGroupApp(app) }
+ .onChange(of: app) { _, newApp in bridge.setGroupApp(newApp) }
+ .onChange(of: bridge.pageReady) { _, ready in
+ if ready { bridge.setGroupApp(app) }
+ }
+ }
+}
+
+private struct GroupAppWebView: UIViewRepresentable {
+ let instance: SavedInstance
+ let thread: ChatThread
+ let myUserId: String
+ let instanceKeyB64: String?
+ let app: String
+
+ func makeUIView(context: Context) -> WKWebView {
+ FeedWebCache.shared.webView(for: instance, thread: thread, myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app)
+ }
+
+ func updateUIView(_ web: WKWebView, context: Context) {}
+}
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 3ebeef6..5a48c61 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -42,6 +42,8 @@ struct NativeChatScreen: View {
let seen = ReadState.lastSeen(instance: instance.id, thread: thread.id)
if seen > 0 { _unreadCutoff = State(initialValue: seen) }
}
+ // Reopen on the view this group was last showing (chat or a shared app).
+ _groupAppID = State(initialValue: GroupAppMemory.selection[thread.id])
}
// The pre-tap last-seen time; non-nil = this open should anchor unreads.
@@ -148,6 +150,34 @@ struct NativeChatScreen: View {
@State private var showMembers = false
@State private var showRename = false
+ @State private var showGroupApps = false
+
+ // The group's chosen view for this session: nil = the conversation, an app
+ // id = that shared app's interface (GroupAppMemory keeps it across pushes,
+ // like the web's groupAppView).
+ @State private var groupAppID: String?
+
+ // The apps this group has added (the model's groupapps fold, already
+ // catalog-filtered). Empty for anything that isn't a group.
+ private var groupApps: [GroupApps.Item] { model.groupApps }
+
+ // The selection, validated against the live fold — an app someone removed
+ // while we were looking at it falls back to the conversation.
+ private var activeGroupAppID: String? {
+ guard let id = groupAppID, groupApps.contains(where: { $0.id == id }) else { return nil }
+ return id
+ }
+
+ // The header menu's view picker ("" = chat) — writes through to the
+ // session memory so reopening the chat restores the same view.
+ private var groupViewSelection: Binding<String> {
+ Binding(
+ get: { activeGroupAppID ?? "" },
+ set: { picked in
+ groupAppID = picked.isEmpty ? nil : picked
+ GroupAppMemory.selection[thread.id] = picked.isEmpty ? nil : picked
+ })
+ }
private var isPinned: Bool {
store.threads.first { $0.id == thread.id }?.pinned ?? thread.pinned
@@ -191,7 +221,16 @@ struct NativeChatScreen: View {
}
var body: some View {
- feed
+ Group {
+ // A picked shared app takes the feed's place (iOS has no split —
+ // like the web on a narrow layout); Chat brings the feed back.
+ if let appID = activeGroupAppID {
+ GroupAppPane(instance: instance, thread: thread, myUserId: store.myUserId,
+ instanceKeyB64: store.instanceKey(), app: appID)
+ } else {
+ feed
+ }
+ }
// The thread view lifts over the FEED only (the web's
// .chatapp-feedwrap overlay) — applied before the safe-area
// insets, so the header pill stays above it and the real composer
@@ -206,10 +245,27 @@ struct NativeChatScreen: View {
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.safeAreaInset(edge: .top, spacing: 0) { chatHeader }
- .safeAreaInset(edge: .bottom) { composerCluster }
+ .safeAreaInset(edge: .bottom) {
+ // An app surface owns the whole pane, inputs included — the
+ // chat composer steps aside (ChatView's interface-view rule).
+ if activeGroupAppID == nil { composerCluster }
+ }
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
+ // The view picker — the same header toggle app chats
+ // have (ChatView's App view/Chat view), grown to list
+ // every app this group has added.
+ if !groupApps.isEmpty {
+ Picker("View", selection: groupViewSelection) {
+ Label("Chat", systemImage: "bubble.left").tag("")
+ ForEach(groupApps) { app in
+ Text("\(GroupApps.glyph(app.id)) \(GroupApps.title(app.id))")
+ .tag(app.id)
+ }
+ }
+ .pickerStyle(.inline)
+ }
// Group management (Mac parity: View Members / Rename).
if isGroup {
Button {
@@ -222,6 +278,11 @@ struct NativeChatScreen: View {
} label: {
Label("Rename Group", systemImage: "pencil")
}
+ Button {
+ showGroupApps = true
+ } label: {
+ Label("Manage Apps", systemImage: "square.grid.2x2")
+ }
}
Button {
store.setPinned(thread, !isPinned)
@@ -249,6 +310,7 @@ struct NativeChatScreen: View {
}
.sheet(isPresented: $showMembers) { MembersSheet(store: store, thread: thread) }
.sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) }
+ .sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) }
.onAppear {
model.start()
LastOpenedChat.note(origin: instance.origin, thread: thread.id,
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift
index 92097ca..002c7d8 100644
--- a/ios/LChat/Views/NewChatView.swift
+++ b/ios/LChat/Views/NewChatView.swift
@@ -113,6 +113,7 @@ struct NewGroupView: View {
let onCreated: (String) -> Void
@State private var selected: Set<String> = []
+ @State private var selectedApps: Set<String> = []
@State private var groupName = ""
@State private var busy = false
@State private var error: String?
@@ -147,6 +148,34 @@ struct NewGroupView: View {
.buttonStyle(.plain)
}
}
+ // Shared apps, picked at birth — each one lands as a groupapps.add
+ // in the fresh log, so every member folds the same list (the same
+ // apps you could add later from ⋯ → Manage Apps).
+ Section {
+ ForEach(GroupApps.catalog, id: \.id) { app in
+ Button { toggleApp(app.id) } label: {
+ HStack(spacing: 12) {
+ Text(app.glyph)
+ .font(.system(size: 15))
+ .frame(width: 32, height: 32)
+ .background(Circle().fill(.primary.opacity(0.06)))
+ Text(app.title).foregroundStyle(.primary)
+ Spacer()
+ if selectedApps.contains(app.id) {
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(.tint)
+ } else {
+ Image(systemName: "circle").foregroundStyle(.tertiary)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ } header: {
+ Text("Apps")
+ } footer: {
+ Text("Apps are shared with everyone in the group — one list, one set of data.")
+ }
if let error {
Section { Text(error).foregroundStyle(.red) }
}
@@ -168,11 +197,16 @@ struct NewGroupView: View {
if selected.contains(id) { selected.remove(id) } else { selected.insert(id) }
}
+ private func toggleApp(_ id: String) {
+ if selectedApps.contains(id) { selectedApps.remove(id) } else { selectedApps.insert(id) }
+ }
+
private func create() {
busy = true
error = nil
Task {
- let id = await store.createChat(memberIDs: Array(selected), groupName: groupName)
+ let id = await store.createChat(memberIDs: Array(selected), groupName: groupName,
+ group: true, apps: Array(selectedApps).sorted())
busy = false
if let id { onCreated(id) } else { error = store.loadError ?? "Couldn't create the group." }
}
diff --git a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
index 8014d98..38998ef 100644
--- a/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
+++ b/ios/LChatCore/Sources/LChatCore/AppChatProjection.swift
@@ -12,6 +12,7 @@ public enum AppChatProjection {
public struct AppEvent: Codable {
public var type: String?
public var text: String?
+ public var app: String? // groupapps.add / groupapps.remove
public var when: Double? // ms since epoch (remind.add)
public var notice: Notice?
public struct Notice: Codable { public var body: String? }
@@ -32,21 +33,38 @@ public enum AppChatProjection {
/// Project one event into a chat line; nil = this event doesn't render
/// (state-only events like todo.toggle, mirroring chatMessage's nulls).
- public static func project(_ e: AppEvent) -> (text: String, action: Bool, appID: String)? {
+ /// `group` = the event lives in a GROUP thread (chatMessage's opts.group):
+ /// a shared Reminders' fire speaks to everyone — the reminder may have
+ /// been set by another member, so "you asked me" would be wrong there.
+ public static func project(_ e: AppEvent, group: Bool = false)
+ -> (text: String, action: Bool, appID: String)? {
switch e.type {
case "remind.fire":
- return ("You asked me to remind you: “\(e.notice?.body ?? "")”", false, "remind")
+ let body = e.notice?.body ?? ""
+ return (group ? "Reminder: “\(body)”" : "You asked me to remind you: “\(body)”",
+ false, "remind")
case "remind.add":
let at = e.when.map(absTime) ?? ""
return ("set a reminder: “\(e.text ?? "")”" + (at.isEmpty ? "" : " — " + at),
true, "remind")
case "todo.add":
return ("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":
+ return ("added \(groupAppTitle(e.app)) to this chat", true, "groupapps")
+ case "groupapps.remove":
+ return ("removed \(groupAppTitle(e.app)) from this chat", true, "groupapps")
default:
return nil
}
}
+ private static func groupAppTitle(_ app: String?) -> String {
+ guard let app, !app.isEmpty else { return "an app" }
+ return GroupApps.title(app)
+ }
+
// remind.js absTime: "Sat 4 Jul at 23:16".
static func absTime(_ ms: Double) -> String {
let f = DateFormatter()
diff --git a/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift b/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift
index ba4ca2a..7266076 100644
--- a/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift
+++ b/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift
@@ -45,7 +45,8 @@ public enum BackgroundSync {
let rows = await api.messages(thread: t.id, after: local.id)
guard !rows.isEmpty else { continue }
let batch: [MessageStore.StoredMessage] = rows.compactMap { row in
- guard let p = ChatFeedModel.renderable(row.blob, key: key) else { return nil }
+ guard let p = ChatFeedModel.renderable(row.blob, key: key,
+ group: t.kind == "group") else { return nil }
return .init(id: row.id, at: row.created_at,
edited: (row.edited_at ?? 0) > 0,
reactions: row.reactions ?? [], payload: p)
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index c00d067..b64fc19 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -71,12 +71,26 @@ public final class ChatFeedModel: ObservableObject {
/// The sender's screen name — the composer needs it to build a /me line.
public var myScreenName: String { instance.screenName }
private let threadID: String
+ private let threadKind: String
private let threadKeyB64: String
private let myUserId: String
private let api: LChatAPI?
private let key: GroupKey?
private var socket: ChatSocket?
+ // ---- group apps (the web's groupapps registry, folded natively) ---------
+
+ /// The mini-apps added to this GROUP chat (⋮ → Add apps on the web),
+ /// already filtered to the catalog like the web's groupAppsFor — always
+ /// empty for DMs/self threads. Drives the header's Chat|App switch.
+ @Published public private(set) var groupApps: [GroupApps.Item] = []
+ // The fold inputs: the encrypted slot-"apps" snapshot's seed + horizon,
+ // plus every groupapps.* event seen (keyed by server message id so pages
+ // can arrive in any order and the recompute stays deterministic).
+ private var groupAppsSeed: [GroupApps.Item] = []
+ private var groupAppsUpTo: Int64 = 0
+ private var groupAppsEvents: [Int64: GroupApps.Event] = [:]
+
// The off-main crypto lane: every per-frame decrypt and every outgoing
// encrypt runs detached through this serial chain — the main actor never
// touches AES — while arrival/send ORDER is preserved (a message and its
@@ -104,6 +118,7 @@ public final class ChatFeedModel: ObservableObject {
public init(instance: SavedInstance, thread: ChatThread, myUserId: String) {
self.instance = instance
self.threadID = thread.id
+ self.threadKind = thread.kind
self.threadKeyB64 = thread.threadKeyB64
self.myUserId = myUserId
self.api = instance.baseURL.map { LChatAPI(baseURL: $0, token: instance.deviceToken) }
@@ -332,6 +347,7 @@ public final class ChatFeedModel: ObservableObject {
return
}
Task { await loadHistory(api) }
+ if threadKind == "group" { Task { await seedGroupApps(api) } }
let socket = ChatSocket(baseURL: base, token: instance.deviceToken)
socket?.onFrame = { [weak self] frame in self?.apply(frame) }
socket?.onStatus = { [weak self] s in
@@ -478,6 +494,81 @@ public final class ChatFeedModel: ObservableObject {
return sent
}
+ // ---- group apps: seed + fold + emit -------------------------------------
+
+ /// Seed the group-apps fold from the thread's encrypted slot-"apps"
+ /// snapshot (what the web saves on every fold advance). History windows at
+ /// 100 messages, so without this an app added long ago would be invisible;
+ /// with it, only events NEWER than the snapshot's horizon matter — exactly
+ /// the window loadHistory already fetches.
+ private func seedGroupApps(_ api: LChatAPI) async {
+ guard let key else { return }
+ guard let state = await api.threadState(thread: threadID, slot: "apps"),
+ !state.blob.isEmpty else { return }
+ let blob = state.blob
+ let parsed = await Task.detached(priority: .userInitiated) {
+ (try? key.decryptData(blob: blob)).flatMap(GroupApps.parseSnapshot)
+ }.value
+ guard let parsed else { return }
+ groupAppsSeed = parsed.seed
+ groupAppsUpTo = parsed.upTo
+ recomputeGroupApps()
+ }
+
+ // The groupapps.* wire shape — the event fields plus the transport
+ // envelope's name/ts (ts is ms; tolerate a float, JS numbers are doubles).
+ private struct GroupAppsWire: Decodable {
+ let type: String?
+ let app: String?
+ let name: String?
+ let ts: Double?
+ }
+
+ nonisolated static func groupAppsEvent(_ blob: String, key: GroupKey) -> GroupApps.Event? {
+ guard let w = try? key.decryptJSON(GroupAppsWire.self, blob: blob),
+ let type = w.type, type.hasPrefix("groupapps."),
+ let app = w.app, !app.isEmpty else { return nil }
+ return GroupApps.Event(type: type, app: app, by: w.name ?? "",
+ ts: Int64(w.ts ?? 0))
+ }
+
+ private func foldGroupAppsEvent(id: Int64, _ e: GroupApps.Event) {
+ guard threadKind == "group", groupAppsEvents[id] != e else { return }
+ groupAppsEvents[id] = e
+ recomputeGroupApps()
+ }
+
+ private func recomputeGroupApps() {
+ let folded = GroupApps.known(GroupApps.fold(seed: groupAppsSeed,
+ upTo: groupAppsUpTo,
+ events: groupAppsEvents))
+ if folded != groupApps { groupApps = folded }
+ }
+
+ /// Add or remove a mini-app on this group — an ordinary encrypted event in
+ /// the group's log (the web's ⋮ → Add apps emit), folded by every member.
+ @discardableResult
+ public func sendGroupAppEvent(add: Bool, app: String) -> Bool {
+ 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,
+ ts: Int64(Date().timeIntervalSince1970 * 1000))
+ if !myUserId.isEmpty { payload.uid = myUserId }
+ encryptAndSend(payload, via: socket)
+ return true
+ }
+
+ // The outgoing groupapps event — shell.js's emit envelope around
+ // {type, app}: stamped with name, ts (ms) and uid like every app event.
+ private struct GroupAppsOut: Encodable {
+ let type: String
+ let app: String
+ let name: String
+ let ts: Int64
+ var uid: String?
+ }
+
/// Broadcast a /me-style action line ("Paul waves") — the composer builds
/// the full text; here it rides with action:true, like the web's ctx.emit.
@discardableResult
@@ -720,12 +811,19 @@ public final class ChatFeedModel: ObservableObject {
guard let key else { return }
let myUserId = self.myUserId
let screenName = self.instance.screenName
- let (fresh, stored): ([Message], [MessageStore.StoredMessage]) =
+ let wantGroupApps = threadKind == "group"
+ let (fresh, stored, appEvents): ([Message], [MessageStore.StoredMessage],
+ [(Int64, GroupApps.Event)]) =
await Task.detached(priority: .userInitiated) {
var out: [Message] = []
var toStore: [MessageStore.StoredMessage] = []
+ var events: [(Int64, GroupApps.Event)] = []
for row in rows {
- guard let p = Self.renderable(row.blob, key: key) else { continue }
+ if wantGroupApps, let e = Self.groupAppsEvent(row.blob, key: key) {
+ events.append((row.id, e))
+ }
+ guard let p = Self.renderable(row.blob, key: key, group: wantGroupApps)
+ else { continue }
toStore.append(.init(id: row.id, at: row.created_at,
edited: (row.edited_at ?? 0) > 0,
reactions: row.reactions ?? [], payload: p))
@@ -739,8 +837,12 @@ public final class ChatFeedModel: ObservableObject {
attachments: p.attachments ?? [], reply: p.replyTo,
reactions: row.reactions ?? [], raw: p))
}
- return (out, toStore)
+ return (out, toStore, events)
}.value
+ if !appEvents.isEmpty {
+ for (id, e) in appEvents { groupAppsEvents[id] = e }
+ recomputeGroupApps()
+ }
let instanceID = instance.id
let tid = threadID
Task.detached(priority: .utility) {
@@ -775,9 +877,13 @@ public final class ChatFeedModel: ObservableObject {
}
guard let id = frame.id, let blob = frame.blob, let key else { return }
let at = frame.created_at ?? 0
- decryptThenFold({ Self.renderable(blob, key: key) }) { model, payload in
- model.foldDecrypted(id: id, at: at, payload: payload,
+ let wantGroupApps = threadKind == "group"
+ decryptThenFold({ (payload: Self.renderable(blob, key: key, group: wantGroupApps),
+ groupApps: wantGroupApps ? Self.groupAppsEvent(blob, key: key) : nil)
+ }) { model, decrypted in
+ model.foldDecrypted(id: id, at: at, payload: decrypted.payload,
edited: false, reactions: [])
+ if let e = decrypted.groupApps { model.foldGroupAppsEvent(id: id, e) }
}
case "typing":
// The dedicated relay frame (bots use this path) — same payload.
@@ -818,6 +924,8 @@ public final class ChatFeedModel: ObservableObject {
}
case "delete":
guard let id = frame.id else { return }
+ // A deleted groupapps event un-folds like the web's refold-on-delete.
+ if groupAppsEvents.removeValue(forKey: id) != nil { recomputeGroupApps() }
var list = messages
list.removeAll { $0.id == id }
Self.refreshReplyCounts(&list)
@@ -974,11 +1082,12 @@ public final class ChatFeedModel: ObservableObject {
// companion apps' Chat view projects them into chat lines on the web, and
// 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) -> MessagePayload? {
+ nonisolated public static func renderable(_ blob: String, key: GroupKey,
+ group: Bool = false) -> 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) else { return nil }
+ let proj = AppChatProjection.project(ev, group: group) else { return nil }
return MessagePayload(type: "chat",
uid: proj.action ? p.uid : nil,
name: proj.action ? p.name : AppChatProjection.title(proj.appID),
diff --git a/ios/LChatCore/Sources/LChatCore/Crypto.swift b/ios/LChatCore/Sources/LChatCore/Crypto.swift
index 1484fcc..b8579d5 100644
--- a/ios/LChatCore/Sources/LChatCore/Crypto.swift
+++ b/ios/LChatCore/Sources/LChatCore/Crypto.swift
@@ -164,6 +164,13 @@ public struct GroupKey {
return s
}
+ /// Decrypts a base64url blob to its raw plaintext bytes — for callers that
+ /// parse tolerantly with JSONSerialization (mini-app state snapshots hold
+ /// several apps' shapes at once) rather than a typed decode.
+ public func decryptData(blob: String) throws -> Data {
+ try decrypt(base64urlBlob: blob)
+ }
+
// MARK: Binary payloads (attachments)
/// Decrypts raw `iv||ct||tag` bytes (attachment downloads are octet-stream,
diff --git a/ios/LChatCore/Sources/LChatCore/GroupApps.swift b/ios/LChatCore/Sources/LChatCore/GroupApps.swift
new file mode 100644
index 0000000..6cb4524
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/GroupApps.swift
@@ -0,0 +1,104 @@
+import Foundation
+
+// 🤖 The native side of the web's `groupapps` registry (apps.js groupAppsApp):
+// which mini-apps a GROUP chat has added is itself E2E fold state — encrypted
+// groupapps.add / groupapps.remove events in the group's own log, seeded from
+// the thread's encrypted slot-"apps" snapshot — so the server never learns
+// which app (or that one) was added. This file is the pure, testable half:
+// the catalog, the snapshot decode and the fold. ChatFeedModel feeds it.
+public enum GroupApps {
+ /// One added app, as the web folds it: the item id IS the app name, so
+ /// re-adding is idempotent and a remove needs no lookup.
+ public struct Item: Equatable, Identifiable, Sendable {
+ public let id: String // the app name ("todo", "bookmarks", …)
+ public var by: String
+ public var ts: Int64 // ms, as stamped by the adder
+ public init(id: String, by: String, ts: Int64) {
+ self.id = id
+ self.by = by
+ self.ts = ts
+ }
+ }
+
+ /// One groupapps.* wire event, keyed by its server message id so pages can
+ /// fold in any arrival order and a recompute stays deterministic.
+ public struct Event: Equatable, Sendable {
+ public let type: String // "groupapps.add" | "groupapps.remove"
+ public let app: String
+ public let by: String
+ public let ts: Int64
+ public init(type: String, app: String, by: String, ts: Int64) {
+ self.type = type
+ self.app = app
+ self.by = by
+ self.ts = ts
+ }
+ }
+
+ /// The apps a group can add — the web's groupCapableApps() (apps declaring
+ /// groupCapable: true). Keep in sync with web/static/apps/*.js like
+ /// ThreadStore.appTitles.
+ public static let catalog: [(id: String, title: String, glyph: String)] = [
+ ("todo", "To-do", "✓"),
+ ("remind", "Reminders", "⏰"),
+ ("bookmarks", "Bookmarks", "🔖"),
+ ("vault", "Vault", "🔒"),
+ ]
+
+ public static func title(_ id: String) -> String {
+ catalog.first { $0.id == id }?.title ?? id
+ }
+
+ public static func glyph(_ id: String) -> String {
+ catalog.first { $0.id == id }?.glyph ?? "▦"
+ }
+
+ /// The web's groupAppsFor filter: resolve folded items against the catalog —
+ /// a hostile or stale add naming anything else stays inert.
+ public static func known(_ items: [Item]) -> [Item] {
+ items.filter { item in catalog.contains { $0.id == item.id } }
+ }
+
+ /// Recompute the added-apps list from the snapshot seed plus every event
+ /// NEWER than the snapshot's horizon, in log order — the web's fold
+ /// (addItem dedupes by id and keeps the original entry; remove deletes).
+ /// `events` is keyed by server message id; ids <= upTo are already inside
+ /// the seed, so they're skipped (apps.js foldInto's `mid <= upTo` rule).
+ public static func fold(seed: [Item], upTo: Int64, events: [Int64: Event]) -> [Item] {
+ var apps = seed
+ for (id, e) in events.sorted(by: { $0.key < $1.key }) {
+ guard id > upTo, !e.app.isEmpty else { continue }
+ switch e.type {
+ case "groupapps.add":
+ if !apps.contains(where: { $0.id == e.app }) {
+ apps.append(Item(id: e.app, by: e.by, ts: e.ts))
+ }
+ case "groupapps.remove":
+ apps.removeAll { $0.id == e.app }
+ default:
+ break
+ }
+ }
+ return apps
+ }
+
+ /// Decode the decrypted slot-"apps" snapshot ({states: {groupapps: {apps:
+ /// [{id, by, ts}]}, …}, upTo}) into a seed. Tolerant by design — other
+ /// apps' states share the blob and any of it may be missing.
+ public static func parseSnapshot(_ json: Data) -> (seed: [Item], upTo: Int64)? {
+ guard let root = (try? JSONSerialization.jsonObject(with: json)) as? [String: Any] else {
+ return nil
+ }
+ let upTo = (root["upTo"] as? NSNumber)?.int64Value ?? 0
+ let states = root["states"] as? [String: Any]
+ let ga = states?["groupapps"] as? [String: Any]
+ let rows = ga?["apps"] as? [[String: Any]] ?? []
+ let seed: [Item] = rows.compactMap { row in
+ guard let id = row["id"] as? String, !id.isEmpty else { return nil }
+ return Item(id: id,
+ by: row["by"] as? String ?? "",
+ ts: (row["ts"] as? NSNumber)?.int64Value ?? 0)
+ }
+ return (seed, upTo)
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift b/ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift
new file mode 100644
index 0000000..9a208f0
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/GroupAppsSheet.swift
@@ -0,0 +1,72 @@
+import SwiftUI
+
+// 🤖 The native twin of the web's ⋮ → "Add apps" sheet (shell.js
+// manageGroupApps): every group-capable app with an Add/Remove action. Adding
+// or removing emits a groupapps event into the group's encrypted log — the
+// button state follows the fold (the echo flips it), so what you see is what
+// every member's fold agrees on. Shared by iOS and the Mac.
+public struct GroupAppsSheet: View {
+ @ObservedObject private var model: ChatFeedModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var error: String?
+
+ public init(model: ChatFeedModel) {
+ self.model = model
+ }
+
+ public var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Apps")
+ .font(.headline)
+ Spacer()
+ Button("Done") { dismiss() }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ Divider()
+ List {
+ Section {
+ ForEach(GroupApps.catalog, id: \.id) { app in
+ row(app)
+ }
+ } footer: {
+ Text("An app added here is shared with everyone in this chat — one app, one set of data, visible to all members.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ if let error {
+ Section { Text(error).foregroundStyle(.red) }
+ }
+ }
+ #if os(macOS)
+ .listStyle(.inset)
+ #else
+ .listStyle(.insetGrouped)
+ #endif
+ }
+ #if os(macOS)
+ .frame(width: 340, height: 400)
+ #endif
+ }
+
+ private func row(_ app: (id: String, title: String, glyph: String)) -> some View {
+ let added = model.groupApps.contains { $0.id == app.id }
+ return HStack(spacing: 12) {
+ Text(app.glyph)
+ .font(.system(size: 17))
+ .frame(width: 32, height: 32)
+ .background(Circle().fill(.primary.opacity(0.06)))
+ Text(app.title)
+ Spacer()
+ Button(added ? "Remove" : "Add") {
+ error = nil
+ if !model.sendGroupAppEvent(add: !added, app: app.id) {
+ error = "You're offline — try again in a moment."
+ }
+ }
+ .buttonStyle(.bordered)
+ .tint(added ? .secondary : .accentColor)
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index a36a66f..66e1ee2 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -129,6 +129,41 @@ public struct LChatAPI {
return r.id
}
+ /// One thread's encrypted mini-app state snapshot (GET
+ /// /api/threads/{id}/state) — the slot-"apps" blob the web saves so a
+ /// client can seed its fold without re-reading the whole log. version 0 +
+ /// empty blob = nothing saved yet.
+ public func threadState(thread: String, slot: String) async -> (blob: String, version: Int64)? {
+ struct Resp: Codable { let blob: String?; let version: Int64? }
+ let items = [
+ URLQueryItem(name: "token", value: token),
+ URLQueryItem(name: "slot", value: slot),
+ ]
+ guard let r = try? await getJSON("/api/threads/\(thread)/state", Resp.self, query: items)
+ else { return nil }
+ return (r.blob ?? "", r.version ?? 0)
+ }
+
+ /// Post one encrypted message over REST — the socketless path (the share
+ /// extension, and thread-creation flows that write before any feed is
+ /// open). The hub still broadcasts to every member. Token in the query,
+ /// like sendViaREST — postMessage reads it there, not from the body.
+ @discardableResult
+ public func postMessage(thread: String, blob: String) async -> Bool {
+ var comps = URLComponents(url: baseURL.appending(path: "/api/messages"),
+ resolvingAgainstBaseURL: false)!
+ comps.queryItems = [URLQueryItem(name: "token", value: token)]
+ guard let url = comps.url,
+ let data = try? JSONSerialization.data(withJSONObject:
+ ["thread_id": thread, "blob": blob]) else { return false }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ guard let (_, resp) = try? await session.data(for: req) else { return false }
+ return ((resp as? HTTPURLResponse)?.statusCode ?? 500) < 300
+ }
+
/// Cancel a pending schedule by the id schedule() returned (the web's
/// ctx.unschedule wire: DELETE /api/schedule/{id}).
@discardableResult
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index dab0481..7dea20a 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -398,7 +398,14 @@ public final class ThreadStore: ObservableObject {
// DM, more = group). Generates a fresh thread key, wraps it to every member
// (including us) via ECIES, encrypts the group name, and creates the thread; the
// server dedupes DMs. Returns the thread id (refreshing the list), or nil on error.
- public func createChat(memberIDs others: [String], groupName: String?) async -> String? {
+ // `group` = the caller's explicit intent (the group flow was used) — the
+ // web's createThread(opts.group) rule, so a one-other-person group is a
+ // real kind:"group" (its name and shared apps stick), never a deduped DM.
+ // `apps` (groups only) seeds the new group's shared apps: one encrypted
+ // groupapps.add event each, posted into the fresh log exactly as the web's
+ // ⋮ → Add apps emits them — so every member (web included) folds the same list.
+ public func createChat(memberIDs others: [String], groupName: String?,
+ group: Bool = false, apps: [String] = []) async -> String? {
guard let api, !myUserId.isEmpty else {
loadError = "Couldn't determine your identity yet — try again in a moment."
return nil
@@ -414,18 +421,22 @@ public final class ThreadStore: ObservableObject {
return nil
}
- let kind = others.isEmpty ? "self" : (others.count == 1 ? "dm" : "group")
+ let kind = others.isEmpty ? "self" : (group || others.count > 1 ? "group" : "dm")
let name = groupName?.trimmingCharacters(in: .whitespaces)
let memberIDs = ids
let memberPubs = pubs
- // Key generation, the name encrypt and one ECDH wrap PER MEMBER — a
- // big group is many milliseconds; all of it happens off main.
- let built: (nameBlob: String, members: [[String: String]])? =
+ let seedApps = kind == "group" ? apps : []
+ let screenName = instance.screenName
+ let uid = myUserId
+ // Key generation, the name encrypt, one ECDH wrap PER MEMBER and the
+ // seed-app event encrypts — a big group is many milliseconds; all of
+ // it happens off main.
+ let built: (nameBlob: String, members: [[String: String]], appBlobs: [String])? =
await Task.detached(priority: .userInitiated) {
let raw = ECIES.randomAESKey()
+ let key = try? GroupKey(rawKey: raw)
var nameBlob = ""
- if kind == "group", let n = name, !n.isEmpty,
- let key = try? GroupKey(rawKey: raw) {
+ if kind == "group", let n = name, !n.isEmpty, let key {
nameBlob = (try? key.encryptJSON(["name": n])) ?? ""
}
var members: [[String: String]] = []
@@ -435,7 +446,23 @@ public final class ThreadStore: ObservableObject {
else { return nil }
members.append(["user_id": id, "wrapped": wrapped])
}
- return (nameBlob, members)
+ // The web's emit envelope around {type:"groupapps.add", app}:
+ // stamped with name, ts (ms) and uid so every client folds and
+ // attributes it identically.
+ var appBlobs: [String] = []
+ if let key {
+ let ts = Int64(Date().timeIntervalSince1970 * 1000)
+ for app in seedApps {
+ var payload: [String: Any] = ["type": "groupapps.add", "app": app,
+ "name": screenName, "ts": ts]
+ if !uid.isEmpty { payload["uid"] = uid }
+ if let data = try? JSONSerialization.data(withJSONObject: payload),
+ let blob = try? key.encryptBytes(data).base64urlEncodedString() {
+ appBlobs.append(blob)
+ }
+ }
+ }
+ return (nameBlob, members, appBlobs)
}.value
guard let built else {
loadError = "Couldn't wrap the chat key."
@@ -447,6 +474,12 @@ public final class ThreadStore: ObservableObject {
loadError = "Couldn't start the chat. Try again."
return nil
}
+ // Seed the shared apps into the fresh log BEFORE anyone opens it, so
+ // the first open already folds them. Best-effort: a lost event just
+ // means adding the app again from the group's menu.
+ for blob in built.appBlobs {
+ await api.postMessage(thread: tid, blob: blob)
+ }
await refresh()
return tid
}
diff --git a/ios/LChatCore/Sources/LChatCore/WebLoadingVeil.swift b/ios/LChatCore/Sources/LChatCore/WebLoadingVeil.swift
new file mode 100644
index 0000000..35cad4a
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/WebLoadingVeil.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+
+// 🤖 The veil a webview surface wears while its page boots — an opaque,
+// theme-matched pane with a quiet spinner, so a loading app reads as
+// "loading" instead of a blank white rectangle. Hosts overlay it until the
+// page posts {ready:true} (shell.js bootEmbed) or navigation settles.
+public struct WebLoadingVeil: View {
+ public init() {}
+
+ public var body: some View {
+ ZStack {
+ #if os(macOS)
+ Color(nsColor: .textBackgroundColor)
+ #else
+ Color(uiColor: .systemBackground)
+ #endif
+ VStack(spacing: 10) {
+ ProgressView()
+ Text("Loading…")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .accessibilityLabel("Loading")
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
new file mode 100644
index 0000000..e08a704
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/GroupAppsVectorTests.swift
@@ -0,0 +1,125 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Cross-platform event contract tests for the group-apps registry —
+// the native fold (GroupApps.fold, fed by ChatFeedModel) must match the web's
+// groupapps reducers exactly. Pinned by test/event-vectors/groupapps.json;
+// the web side runs the same vectors in test/event-vectors.test.mjs.
+final class GroupAppsVectorTests: XCTestCase {
+ private func fold(seed: [GroupApps.Item] = [], upTo: Int64 = 0,
+ _ events: [Int64: GroupApps.Event]) -> [GroupApps.Item] {
+ GroupApps.fold(seed: seed, upTo: upTo, events: events)
+ }
+
+ // groupapps_add: a member adds a trusted app to the group.
+ func testAdd() {
+ let out = fold([1: .init(type: "groupapps.add", app: "todo", by: "Ana", ts: 1_700_000_000_000)])
+ XCTAssertEqual(out, [GroupApps.Item(id: "todo", by: "Ana", ts: 1_700_000_000_000)])
+ }
+
+ // groupapps_add_duplicate: idempotent by app name — the original add's
+ // attribution is kept, a duplicate never rewrites by/ts.
+ func testDuplicateAddKeepsOriginalAttribution() {
+ let out = fold([
+ 1: .init(type: "groupapps.add", app: "todo", by: "Ana", ts: 1_700_000_000_000),
+ 2: .init(type: "groupapps.add", app: "todo", by: "Bob", ts: 1_700_000_005_000),
+ ])
+ XCTAssertEqual(out, [GroupApps.Item(id: "todo", by: "Ana", ts: 1_700_000_000_000)])
+ }
+
+ // groupapps_remove: removing folds the app out again.
+ func testRemove() {
+ let out = fold([
+ 1: .init(type: "groupapps.add", app: "todo", by: "Ana", ts: 1_700_000_000_000),
+ 2: .init(type: "groupapps.remove", app: "todo", by: "Ana", ts: 1_700_000_010_000),
+ ])
+ XCTAssertEqual(out, [])
+ }
+
+ // groupapps_add_malformed: an add with no app name is a no-op, never a crash.
+ func testMalformedAddIsNoop() {
+ let out = fold([1: .init(type: "groupapps.add", app: "", by: "Mallory", ts: 0)])
+ XCTAssertEqual(out, [])
+ }
+
+ // groupapps_remove_missing: removing an app that isn't added is a no-op.
+ func testRemoveMissingIsNoop() {
+ let out = fold([1: .init(type: "groupapps.remove", app: "todo", by: "Ana", ts: 0)])
+ XCTAssertEqual(out, [])
+ }
+
+ // Fold order is server-message-id order, whatever order pages arrived in.
+ func testFoldsInMessageIDOrder() {
+ let out = fold([
+ 3: .init(type: "groupapps.remove", app: "todo", by: "Ana", ts: 3),
+ 1: .init(type: "groupapps.add", app: "todo", by: "Ana", ts: 1),
+ 2: .init(type: "groupapps.add", app: "bookmarks", by: "Bob", ts: 2),
+ ])
+ XCTAssertEqual(out, [GroupApps.Item(id: "bookmarks", by: "Bob", ts: 2)])
+ }
+
+ // Events at or below the snapshot horizon are already inside the seed —
+ // refolding them must not double-apply (apps.js foldInto's `mid <= upTo`).
+ func testSnapshotHorizonSkipsSeededEvents() {
+ let seed = [GroupApps.Item(id: "todo", by: "Ana", ts: 1)]
+ let out = fold(seed: seed, upTo: 10, [
+ // A stale remove BELOW the horizon must not fold (the snapshot already
+ // reflects whatever it did).
+ 9: .init(type: "groupapps.remove", app: "todo", by: "Ana", ts: 9),
+ // A fresh add above it does.
+ 11: .init(type: "groupapps.add", app: "vault", by: "Bob", ts: 11),
+ ])
+ XCTAssertEqual(out, [GroupApps.Item(id: "todo", by: "Ana", ts: 1),
+ GroupApps.Item(id: "vault", by: "Bob", ts: 11)])
+ }
+
+ // Unknown app names fold into state (log convergence) but are filtered at
+ // read time — the web's groupAppsFor rule.
+ func testUnknownAppsStayInert() {
+ let out = fold([1: .init(type: "groupapps.add", app: "evil-app", by: "Mallory", ts: 0)])
+ XCTAssertEqual(out.count, 1)
+ XCTAssertEqual(GroupApps.known(out), [])
+ }
+
+ // The chat projection — apps.js groupAppsApp.chatMessage, word for word
+ // (the group fold prefixes the actor's name onto action lines).
+ func testChatProjection() {
+ let add = AppChatProjection.project(.init(type: "groupapps.add", app: "todo"))
+ XCTAssertEqual(add?.text, "added To-do to this chat")
+ XCTAssertEqual(add?.action, true)
+ let remove = AppChatProjection.project(.init(type: "groupapps.remove", app: "todo"))
+ XCTAssertEqual(remove?.text, "removed To-do from this chat")
+ XCTAssertEqual(remove?.action, true)
+ let anonymous = AppChatProjection.project(.init(type: "groupapps.add"))
+ XCTAssertEqual(anonymous?.text, "added an app to this chat")
+ }
+
+ // 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:).
+ func testRemindFireWordingInGroups() {
+ let e = AppChatProjection.AppEvent(type: "remind.fire", notice: .init(body: "standup"))
+ XCTAssertEqual(AppChatProjection.project(e)?.text,
+ "You asked me to remind you: “standup”")
+ XCTAssertEqual(AppChatProjection.project(e, group: true)?.text,
+ "Reminder: “standup”")
+ }
+
+ // The slot-"apps" snapshot decode: {states: {groupapps: {apps}}, upTo} —
+ // tolerant of other apps' states sharing the blob.
+ func testSnapshotParse() {
+ let json = """
+ {"states":{"todo":{"items":[]},"groupapps":{"apps":[{"id":"todo","by":"Ana","ts":1700000000000}]}},"upTo":42}
+ """.data(using: .utf8)!
+ let parsed = GroupApps.parseSnapshot(json)
+ XCTAssertEqual(parsed?.upTo, 42)
+ XCTAssertEqual(parsed?.seed, [GroupApps.Item(id: "todo", by: "Ana", ts: 1_700_000_000_000)])
+ }
+
+ func testSnapshotParseWithoutGroupApps() {
+ let json = #"{"states":{"todo":{"items":[]}},"upTo":7}"#.data(using: .utf8)!
+ let parsed = GroupApps.parseSnapshot(json)
+ XCTAssertEqual(parsed?.upTo, 7)
+ XCTAssertEqual(parsed?.seed, [])
+ }
+}
diff --git a/ios/LChatMac/AppThreadWebView.swift b/ios/LChatMac/AppThreadWebView.swift
index 6ed5c4b..40f8b4e 100644
--- a/ios/LChatMac/AppThreadWebView.swift
+++ b/ios/LChatMac/AppThreadWebView.swift
@@ -8,18 +8,25 @@ import WebKit
// same way iOS's FeedWebCache seeds its per-thread pages: window.__lchatEmbed
// carries the device token + THIS thread's unwrapped key (never the user's
// private key), so the page can render exactly one thread and nothing else.
+//
+// `app` names one of a GROUP's added apps: that pane renders the app's
+// interface (the group's shared widget) instead of a personal app-thread's.
+// One cached page serves all the group's apps — switching is a JS call
+// (MacWebBridge.setGroupApp), never a reload.
struct AppThreadWebView: NSViewRepresentable {
let instance: SavedInstance
let thread: ChatThread
let myUserId: String
let instanceKeyB64: String?
+ var app: String? = nil
// Hands out the CACHED page — a detached WKWebView keeps its page, so
// toggling Chat | Both | View reattaches the same live app instead of
// reloading it on every click.
func makeNSView(context: Context) -> WKWebView {
AppWebViewCache.shared.webView(for: instance, thread: thread,
- myUserId: myUserId, instanceKeyB64: instanceKeyB64)
+ myUserId: myUserId, instanceKeyB64: instanceKeyB64,
+ app: app)
}
func updateNSView(_ web: WKWebView, context: Context) {}
@@ -27,7 +34,8 @@ struct AppThreadWebView: NSViewRepresentable {
// Mirrors FeedWebCache.bootScript (iOS): one global that both flags embed
// mode and carries the seed.
fileprivate static func bootScript(instance: SavedInstance, thread: ChatThread,
- myUserId: String, instanceKeyB64: String?) -> WKUserScript? {
+ myUserId: String, instanceKeyB64: String?,
+ app: String?) -> WKUserScript? {
var seed: [String: Any] = [
"token": instance.deviceToken,
"name": instance.screenName,
@@ -39,6 +47,8 @@ struct AppThreadWebView: NSViewRepresentable {
// The native split renders chat itself — this page is purely the
// app's INTERFACE (app threads otherwise default to their chat view).
seed["view"] = "interface"
+ // A group pane also names which added app to show (shell.js bootEmbed).
+ if let app { seed["app"] = app }
guard let data = try? JSONSerialization.data(withJSONObject: seed),
let json = String(data: data, encoding: .utf8) else { return nil }
return WKUserScript(source: "window.__lchatEmbed = \(json);",
@@ -46,6 +56,128 @@ struct AppThreadWebView: NSViewRepresentable {
}
}
+// An app webview surface with its loading state handled: the veil sits over
+// the pane until the page reports it has painted ({ready:true} from shell.js
+// bootEmbed, didFinish + a beat as the fallback) — a booting app reads as
+// "loading", never a blank white pane. Wraps every Mac app-pane use of
+// AppThreadWebView; also re-asserts a group pane's selected app on switches.
+struct AppWebSurface: View {
+ @ObservedObject private var bridge: MacWebBridge
+ let instance: SavedInstance
+ let thread: ChatThread
+ let myUserId: String
+ let instanceKeyB64: String?
+ var app: String? = nil
+
+ @MainActor
+ init(instance: SavedInstance, thread: ChatThread, myUserId: String,
+ instanceKeyB64: String?, app: String? = nil) {
+ self.instance = instance
+ self.thread = thread
+ self.myUserId = myUserId
+ self.instanceKeyB64 = instanceKeyB64
+ self.app = app
+ bridge = AppWebViewCache.shared.bridge(for: instance, thread: thread,
+ myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app)
+ }
+
+ var body: some View {
+ ZStack {
+ AppThreadWebView(instance: instance, thread: thread, myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app)
+ if !bridge.pageReady {
+ WebLoadingVeil().transition(.opacity)
+ }
+ }
+ .animation(.easeOut(duration: 0.25), value: bridge.pageReady)
+ // The boot seed carries the app chosen at page-creation time; every
+ // appearance/switch (and the ready flip, for a pane that appeared
+ // mid-boot) re-asserts the current one.
+ .onAppear { if let app { bridge.setGroupApp(app) } }
+ .onChange(of: app) { _, newApp in if let newApp { bridge.setGroupApp(newApp) } }
+ .onChange(of: bridge.pageReady) { _, ready in
+ if ready, let app { bridge.setGroupApp(app) }
+ }
+ }
+}
+
+// The Mac cousin of iOS's WebBridge, scoped to what app panes need: the
+// page-ready flag for the loading veil, the setGroupApp switch, and the
+// target="_blank" fix (without a UI delegate those links fell on the floor —
+// Bookmarks lives on them). Not actor-isolated, like WebBridge: WebKit calls
+// every delegate method on the main thread already.
+final class MacWebBridge: NSObject, ObservableObject, WKNavigationDelegate,
+ WKScriptMessageHandler, WKUIDelegate {
+ weak var webView: WKWebView?
+
+ // True once the page has real content. shell.js posts {ready:true} after
+ // its first render — the honest "painted" moment; didFinish is only the
+ // fallback (it fires when the HTML lands, before the app has drawn).
+ @Published var pageReady = false
+
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
+ pageReady = false
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
+ self?.pageReady = true
+ }
+ }
+
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ pageReady = true // show whatever the page has — a veil that never lifts is worse
+ }
+
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
+ withError error: Error) {
+ pageReady = true
+ }
+
+ func userContentController(_ controller: WKUserContentController,
+ didReceive message: WKScriptMessage) {
+ guard message.name == "lchat", let body = message.body as? [String: Any],
+ body["ready"] as? Bool == true else { return }
+ pageReady = true
+ }
+
+ // Message links render as target="_blank" anchors — route them to the
+ // default browser instead of spawning a child webview.
+ func webView(_ webView: WKWebView,
+ createWebViewWith configuration: WKWebViewConfiguration,
+ for navigationAction: WKNavigationAction,
+ windowFeatures: WKWindowFeatures) -> WKWebView? {
+ if let url = navigationAction.request.url {
+ NSWorkspace.shared.open(url)
+ }
+ return nil
+ }
+
+ // Swap which of a group's added apps the shared pane shows (shell.js
+ // setGroupApp) — `app` is a catalog id (ours, never user text). The group
+ // pane must NEVER sit on the chat view: against a server whose shell.js
+ // predates setGroupApp/embed.app, fall back to setAppView("interface")
+ // (the old bridge routes a group with apps to its app pane — just without
+ // per-app choice). The page's bridge installs a beat after didFinish, so
+ // retry until one of the calls lands.
+ func setGroupApp(_ app: String, attempt: Int = 0) {
+ let js = """
+ (function(){ var n = window.lchatNative;
+ if (!n) return false;
+ if (n.setGroupApp) { n.setGroupApp('\(app)'); return true; }
+ if (n.setAppView) { n.setAppView('interface'); return true; }
+ return false; })()
+ """
+ webView?.evaluateJavaScript(js) { [weak self] result, _ in
+ guard (result as? Bool) != true, attempt < 12 else { return }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ self?.setGroupApp(app, attempt: attempt + 1)
+ }
+ }
+ }
+}
+
// Keeps each app thread's webview alive across Chat | Both | View toggles and
// thread switches — a detached WKWebView keeps its page, so reattaching is
// instant with no reload. Small LRU; the Mac cousin of iOS's FeedWebCache.
@@ -55,38 +187,66 @@ final class AppWebViewCache {
private struct Entry {
let web: WKWebView
+ let bridge: MacWebBridge
let seedKey: String
var lastUsed = Date()
}
- private var entries: [String: Entry] = [:] // "instanceID|threadID"
+ private var entries: [String: Entry] = [:] // "instanceID|threadID[|groupapps]"
private let maxEntries = 6
func webView(for instance: SavedInstance, thread: ChatThread,
- myUserId: String, instanceKeyB64: String?) -> WKWebView {
- let key = instance.id + "|" + thread.id
+ myUserId: String, instanceKeyB64: String?, app: String? = nil) -> WKWebView {
+ entry(for: instance, thread: thread, myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app).web
+ }
+
+ func bridge(for instance: SavedInstance, thread: ChatThread,
+ myUserId: String, instanceKeyB64: String?, app: String? = nil) -> MacWebBridge {
+ entry(for: instance, thread: thread, myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, app: app).bridge
+ }
+
+ private func entry(for instance: SavedInstance, thread: ChatThread,
+ myUserId: String, instanceKeyB64: String?, app: String?) -> Entry {
+ // One shared pane per group for ALL its apps (switching is a JS call,
+ // not a reload), so the key marks the surface, not the app.
+ let key = instance.id + "|" + thread.id + (app != nil ? "|groupapps" : "")
// Reuse only if seeded with the same thread key — a re-wrap = stale page.
if var e = entries[key], e.seedKey == thread.threadKeyB64 {
e.lastUsed = Date()
entries[key] = e
- return e.web
+ return e
}
- entries.removeValue(forKey: key)?.web.stopLoading()
+ if let old = entries.removeValue(forKey: key) { release(old) }
+ let bridge = MacWebBridge()
let cfg = WKWebViewConfiguration()
cfg.websiteDataStore = .default()
if let script = AppThreadWebView.bootScript(instance: instance, thread: thread,
myUserId: myUserId,
- instanceKeyB64: instanceKeyB64) {
+ instanceKeyB64: instanceKeyB64,
+ app: app) {
cfg.userContentController.addUserScript(script)
}
+ cfg.userContentController.add(bridge, name: "lchat")
let web = WKWebView(frame: .zero, configuration: cfg)
+ bridge.webView = web
+ web.navigationDelegate = bridge
+ web.uiDelegate = bridge
if let url = instance.baseURL { web.load(URLRequest(url: url)) }
- entries[key] = Entry(web: web, seedKey: thread.threadKeyB64)
+ let e = Entry(web: web, bridge: bridge, seedKey: thread.threadKeyB64)
+ entries[key] = e
while entries.count > maxEntries,
let oldest = entries.filter({ $0.value.web.superview == nil })
.min(by: { $0.value.lastUsed < $1.value.lastUsed }) {
- entries.removeValue(forKey: oldest.key)?.web.stopLoading()
+ if let old = entries.removeValue(forKey: oldest.key) { release(old) }
}
- return web
+ return e
+ }
+
+ private func release(_ e: Entry) {
+ e.web.stopLoading()
+ // The controller retains the bridge; break that so the whole page tears down.
+ e.web.configuration.userContentController.removeScriptMessageHandler(forName: "lchat")
}
}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 3e8a17e..4a384d8 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -166,6 +166,9 @@ struct NativeChatView: View {
.sheet(item: $renameSheetThread) { t in
RenameGroupSheet(store: store, thread: t)
}
+ .sheet(item: $appsSheetThread) { t in
+ GroupAppsSheet(model: feedModel(t))
+ }
.sheet(isPresented: $showInvite) {
InviteSomeoneSheet(store: store)
}
@@ -359,14 +362,36 @@ struct NativeChatView: View {
appPaneResizeHandle
}
if mode != .chat {
- AppThreadWebView(instance: instance, thread: thread,
- myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
+ AppWebSurface(instance: instance, thread: thread,
+ myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
.id(thread.id + "|" + thread.threadKeyB64 + "|app")
.frame(width: mode == .split ? appPaneWidth : nil)
}
}
}
+ // A GROUP with shared apps (web: Chat | Split | <one slot per app>): same
+ // split shape as a companion app-thread, but the pane shows whichever
+ // added app the header dropdown picked. Folded reactively — the surface
+ // observes the thread's model, so an app added/removed by any member (or
+ // the New-group seeding) reshapes it live; a group with no apps is just
+ // the plain feed.
+ @ViewBuilder private func groupSurface(_ thread: ChatThread) -> some View {
+ GroupThreadSurface(model: feedModel(thread),
+ mode: groupModeBinding(thread),
+ selection: groupAppSelBinding(thread),
+ paneWidth: appPaneWidth,
+ feed: { AnyView(nativeFeed(thread)) },
+ handle: { AnyView(appPaneResizeHandle) },
+ pane: { appID in
+ AnyView(AppWebSurface(instance: instance, thread: thread,
+ myUserId: store.myUserId,
+ instanceKeyB64: store.instanceKey(),
+ app: appID)
+ .id(thread.id + "|" + thread.threadKeyB64 + "|group-pane"))
+ })
+ }
+
// Chat = a bubble, Both = a split pane, View = the app's own face.
private func appModeSwitch(_ t: ChatThread) -> some View {
let binding = appModeBinding(t)
@@ -479,6 +504,14 @@ struct NativeChatView: View {
// avatar (a stock segmented control only takes text/images).
if isCompanion(t) {
appModeSwitch(t)
+ } else if t.kind == "group" {
+ // A group with shared apps gets the same bar, its third
+ // segment a dropdown of the added apps. Observes the thread's
+ // model so the bar appears/disappears as apps are added or
+ // removed (by anyone).
+ GroupAppsHeaderControls(model: feedModel(t)) { apps in
+ AnyView(groupModeSwitch(t, apps: apps))
+ }
}
Menu {
threadMenu(t)
@@ -509,6 +542,7 @@ struct NativeChatView: View {
if t.kind == "group" {
Button("View Members…") { membersSheetThread = t }
Button("Rename Group…") { renameSheetThread = t }
+ Button("Manage Apps…") { appsSheetThread = t }
Divider()
}
Button(t.pinned ? "Unpin" : "Pin") { store.setPinned(t, !t.pinned) }
@@ -551,15 +585,97 @@ struct NativeChatView: View {
set: { appModes[t.id] = $0 })
}
+ // ---- group apps: the shared widgets a group added (web's groupapps) ----
+
+ // Which added app a group's pane shows, per thread, session-only — the
+ // web's groupAppView.
+ @State private var groupAppSel: [String: String] = [:]
+ // The manage-apps sheet's thread (⋮ → Manage Apps…).
+ @State private var appsSheetThread: ChatThread?
+
+ private func feedModel(_ t: ChatThread) -> ChatFeedModel {
+ NativeFeedModelCache.shared.model(for: instance, thread: t, myUserId: store.myUserId)
+ }
+
+ // Groups default to the conversation ("a group is first a chat", the web's
+ // rule) — the companion binding above defaults to .split instead.
+ private func groupModeBinding(_ t: ChatThread) -> Binding<AppViewMode> {
+ Binding(get: { appModes[t.id] ?? .chat },
+ set: { appModes[t.id] = $0 })
+ }
+
+ private func groupAppSelBinding(_ t: ChatThread) -> Binding<String?> {
+ Binding(get: { groupAppSel[t.id] },
+ set: { groupAppSel[t.id] = $0 })
+ }
+
+ // The group header's controls: the SAME Chat | Both | View segments a
+ // companion app-thread gets (the third segment wears the shown app's
+ // glyph and zooms it full-pane), plus a separate menu button OUTSIDE the
+ // group — the view selector: pick which added app the pane shows, and
+ // Manage Apps….
+ private func groupModeSwitch(_ t: ChatThread, apps: [GroupApps.Item]) -> some View {
+ let binding = groupModeBinding(t)
+ let sel = groupAppSelBinding(t)
+ let active = apps.first { $0.id == sel.wrappedValue }?.id ?? apps.first?.id
+ return HStack(spacing: 6) {
+ HStack(spacing: 2) {
+ appModeButton(.chat, binding) {
+ Image(systemName: "bubble.left")
+ .font(.system(size: 12, weight: .medium))
+ }
+ appModeButton(.split, binding) {
+ Image(systemName: "rectangle.split.2x1")
+ .font(.system(size: 12, weight: .medium))
+ }
+ appModeButton(.interface, binding) {
+ Text(GroupApps.glyph(active ?? ""))
+ .font(.system(size: 11))
+ }
+ }
+ .padding(2)
+ .background(RoundedRectangle(cornerRadius: 7, style: .continuous)
+ .fill(.primary.opacity(0.06)))
+ // The view selector, its own affordance beside the segments:
+ // glyph + name per added app (picking one shows it — beside the
+ // chat first, like tapping its web widget button), then the
+ // management door.
+ Menu {
+ ForEach(apps) { app in
+ Button {
+ sel.wrappedValue = app.id
+ if binding.wrappedValue == .chat { binding.wrappedValue = .split }
+ } label: {
+ Text("\(GroupApps.glyph(app.id)) \(GroupApps.title(app.id))")
+ }
+ }
+ Divider()
+ Button("Manage Apps…") { appsSheetThread = t }
+ } label: {
+ Image(systemName: "chevron.down")
+ .font(.system(size: 12, weight: .medium))
+ .frame(width: 28, height: 28, alignment: .center)
+ .contentShape(Rectangle())
+ }
+ .menuStyle(.button)
+ .buttonStyle(.plain)
+ .menuIndicator(.hidden)
+ .hoverCircle()
+ .help("Apps")
+ }
+ }
+
@ViewBuilder private var feed: some View {
if let thread = selectedThread, isCompanion(thread) {
companionSplit(thread)
} else if let thread = selectedThread, thread.isApp {
// Takeover mini-apps get the app's own web interface, full pane —
// never the raw native event-log feed.
- AppThreadWebView(instance: instance, thread: thread,
- myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
+ AppWebSurface(instance: instance, thread: thread,
+ myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
.id(thread.id + "|" + thread.threadKeyB64)
+ } else if let thread = selectedThread, thread.kind == "group" {
+ groupSurface(thread)
} else if let thread = selectedThread {
nativeFeed(thread)
} else {
@@ -1123,3 +1239,56 @@ private struct HoverSquircle: ViewModifier {
extension View {
func hoverCircle() -> some View { modifier(HoverSquircle()) }
}
+
+// 🤖 The reactive shells around a group's shared apps. Both observe the
+// thread's ChatFeedModel (its groupapps fold), so NativeChatView itself —
+// which can't @ObservedObject a per-selection model — stays put while the
+// bar and the split reshape live as apps are added and removed.
+
+// Renders the header's group view switch only while the group has apps.
+private struct GroupAppsHeaderControls: View {
+ @ObservedObject var model: ChatFeedModel
+ let content: ([GroupApps.Item]) -> AnyView
+
+ var body: some View {
+ if !model.groupApps.isEmpty {
+ content(model.groupApps)
+ }
+ }
+}
+
+// The group feed surface: the plain native feed until an app view is picked,
+// then the same split shape a companion app-thread uses — feed | handle |
+// pane — with the pane showing the dropdown's selected app. Falls back to
+// the conversation whenever the picked app (or every app) is removed.
+private struct GroupThreadSurface: View {
+ @ObservedObject var model: ChatFeedModel
+ @Binding var mode: NativeChatView.AppViewMode
+ @Binding var selection: String?
+ let paneWidth: Double
+ let feed: () -> AnyView
+ let handle: () -> AnyView
+ let pane: (String) -> AnyView
+
+ // The app the pane shows: the sticky pick while it's still added, else
+ // the first added app (the web's groupAppSelection).
+ private var activeApp: String? {
+ let apps = model.groupApps
+ guard !apps.isEmpty else { return nil }
+ if let sel = selection, apps.contains(where: { $0.id == sel }) { return sel }
+ return apps.first?.id
+ }
+
+ var body: some View {
+ if let app = activeApp, mode != .chat {
+ HStack(spacing: 0) {
+ if mode != .interface { feed() }
+ if mode == .split { handle() }
+ pane(app)
+ .frame(width: mode == .split ? paneWidth : nil)
+ }
+ } else {
+ feed()
+ }
+ }
+}
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift
index 306e2d3..210ee4f 100644
--- a/ios/LChatMac/NewChatPopover.swift
+++ b/ios/LChatMac/NewChatPopover.swift
@@ -12,6 +12,7 @@ struct NewChatPopover: View {
@State private var groupMode = false
@State private var selected: Set<String> = []
+ @State private var selectedApps: Set<String> = []
@State private var groupName = ""
@State private var busy = false
@State private var error: String?
@@ -55,6 +56,7 @@ struct NewChatPopover: View {
Button(groupMode ? "Single chat" : "New group…") {
groupMode.toggle()
selected = []
+ selectedApps = []
}
.buttonStyle(.link)
.font(.system(size: 12))
@@ -77,7 +79,9 @@ struct NewChatPopover: View {
}
// Personal apps, like the web's New-chat APPS section:
// click to open (created on first use). Not group members,
- // so they hide in group mode.
+ // so in group mode this becomes SHARED APPS instead —
+ // tick the apps the new group should start with (each one
+ // lands as a groupapps.add in the fresh log).
if !groupMode {
Text("APPS")
.font(.system(size: 10, weight: .semibold))
@@ -87,6 +91,15 @@ struct NewChatPopover: View {
ForEach(ThreadStore.personalApps, id: \.id) { appEntry in
appRow(appEntry)
}
+ } else {
+ Text("SHARED APPS")
+ .font(.system(size: 10, weight: .semibold))
+ .kerning(0.5)
+ .foregroundStyle(.tertiary)
+ .padding(EdgeInsets(top: 10, leading: 8, bottom: 2, trailing: 0))
+ ForEach(GroupApps.catalog, id: \.id) { appEntry in
+ groupAppRow(appEntry)
+ }
}
}
}
@@ -302,11 +315,44 @@ struct NewChatPopover: View {
.disabled(busy)
}
+ // A tickable row for a shared app in group mode — same shape as personRow's
+ // multi-select, the app's glyph for a face.
+ private func groupAppRow(_ entry: (id: String, title: String, glyph: String)) -> some View {
+ Button {
+ if selectedApps.contains(entry.id) {
+ selectedApps.remove(entry.id)
+ } else {
+ selectedApps.insert(entry.id)
+ }
+ } label: {
+ HStack(spacing: 8) {
+ NativeChatView.ThreadAvatar(title: entry.title, source: nil,
+ size: 26, glyph: entry.glyph)
+ Text(entry.title)
+ .font(.system(size: 13, weight: .medium))
+ .lineLimit(1)
+ Spacer()
+ Image(systemName: selectedApps.contains(entry.id)
+ ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(selectedApps.contains(entry.id)
+ ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(busy)
+ }
+
private func create(_ memberIDs: [String], name: String?) {
busy = true
error = nil
+ let apps = groupMode ? Array(selectedApps).sorted() : []
Task {
- let threadID = await store.createChat(memberIDs: memberIDs, groupName: name)
+ let threadID = await store.createChat(memberIDs: memberIDs, groupName: name,
+ group: groupMode, apps: apps)
busy = false
if let threadID {
onCreated(threadID)
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift
index 9ce76a9..7886261 100644
--- a/ios/LChatNSE/NotificationService.swift
+++ b/ios/LChatNSE/NotificationService.swift
@@ -80,7 +80,7 @@ final class NotificationService: UNNotificationServiceExtension {
// the feed folds (renderable), so app events land as their chat lines.
guard let id = (info["id"] as? NSNumber)?.int64Value, id > 0,
let at = (info["at"] as? NSNumber)?.int64Value,
- let stored = ChatFeedModel.renderable(blob, key: key)
+ let stored = ChatFeedModel.renderable(blob, key: key, group: entry.group)
else {
ship(deliver)
return
diff --git a/web/static/shell.js b/web/static/shell.js
index da80462..d4029ed 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -798,11 +798,23 @@ async function bootEmbed() {
// interface, so without this the pane would default to the chat view and
// show a second, redundant web chat beside the native one.
if (embed.view && threadApp[tid]) appThreadView[tid] = embed.view;
+ // 🤖 A GROUP's shared app pane: the native shell names which added app to
+ // show (its own header switch renders chat natively, so this page is
+ // purely that app's interface). Assigned directly — the groupapps fold
+ // hasn't run yet, so setAppThreadView's guard would reject it here; the
+ // open below folds state and refreshGroupAppsUI applies the view.
+ if (embed.app) {
+ groupAppView[tid] = embed.app;
+ appThreadView[tid] = "interface";
+ }
renderApp();
await openThread(tid);
connectWS();
installNativeBridge();
startHeartbeat();
+ // The pane has real content now — the native shell drops its loading
+ // veil on this (didFinish fires long before the app has painted).
+ nativePost({ ready: true });
} catch (e) {
console.error(e);
nativePost({ error: "Couldn't open this chat." });
@@ -869,6 +881,18 @@ function installNativeBridge() {
if (!tid) return;
setAppThreadView(tid, view === "interface" ? "interface" : "chat");
},
+ // Swap which of a GROUP's added apps the Interface pane shows — the native
+ // header's app dropdown drives this without a page reload (the pane
+ // re-renders from already-folded state). Unknown names stay inert:
+ // renderGroupAppPane resolves via groupAppsFor, which filters to the
+ // catalog of group-capable apps.
+ setGroupApp(app) {
+ const tid = embed && embed.threadId;
+ if (!tid || typeof app !== "string" || !app) return;
+ groupAppView[tid] = app;
+ appThreadView[tid] = "interface";
+ refreshGroupAppsUI(tid);
+ },
// 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.