eleven / messenger Public

Foreground vault pull + Mac drops the full-frame webview (sign-in is the only door) (#466)

* ios+mac: foreground vault pull — home-page stars/archive land in the apps

Sync was push-only after sign-in, so a star made on
home.elevenmessenger.com sat in the vault until the next ceremony. The
saved session already holds the blob key, so HomeSync.pull fetches and
opens the vault silently (rate-limited); iOS pulls on launch +
foreground, the Mac on every app activation. The merge is the sign-in
vault-wins rule — and it UPSERTS: a space signed into on another device
appears here too, credentials riding the vault.

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

* mac: the full-frame webview is gone — sign-in is the only door

Paul's call: the ONLY way into the native Mac app is a passkey — no
more paste-a-link. Removed: the ChatFeedWebView fallback surface (a
chat without a native identity now shows a native locked pane with
Remove, instead of the web app), AddChatView + every Add Chat entry
point (menu ⇧⌘N, rail, first-run link), invite-link URL handling (the
home-session sign-in callback is the one link the app accepts), and the
palette's webview bridge (runWeb/actions/connect — chats come from the
native publisher only). Joining lives on the web; signing in brings
spaces here, credentials riding the vault (incl. the new foreground
pull). Scoped webviews (mini-app panels) stay.

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

* ios: paste-a-link join is gone too — sign-in is the only door on both platforms

Removed AddInstanceView/JoinWebView (the paste/QR sheet), the drawer's
'Add a chat', the first-run invite link, and invite-URL handling. The
iOS 17 first-run (no PRF) now says to update or use the browser rather
than offering a join that no longer exists. A NEW native join flow (no
pasted links) is future work — tracked.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bf6fd104c4baace831ee3c7b2106074dba90f37b parent 4f87503
12 files changed, +125 −1373
  • ios/LChat/LChatApp.swift +39 −34
  • ios/LChat/Views/InstanceSidebar.swift +0 −10
  • ios/LChat/Views/JoinView.swift +0 −414
  • ios/LChat/Views/SignInWithEleven.swift +1 −6
  • ios/LChatCore/Sources/LChatCore/HomeSync.swift +16 −0
  • ios/LChatMac/AddChatView.swift +0 −174
  • ios/LChatMac/ChatFeedWebView.swift +0 −580
  • ios/LChatMac/LChatMacApp.swift +25 −31
  • ios/LChatMac/MacHomeSignIn.swift +1 −5
  • ios/LChatMac/MacInstance.swift +5 −9
  • ios/LChatMac/RootView.swift +38 −109
  • ios/LChatMac/Sidebar.swift +0 −1
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 92aaffd..ca2427d 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -139,6 +139,27 @@ final class AppState: ObservableObject {
HomeSync.pushSoon(instances: instances)
}
+ /// Merge a PULLED vault into this device (the foreground sync): spaces
+ /// signed into elsewhere are added (their credentials ride the vault),
+ /// then the vault's stars/archive/names/order apply — the same vault-wins
+ /// rule as sign-in. No push back: the device state now equals the vault.
+ func merge(vault servers: [HomeVault.Server]) {
+ for s in servers {
+ guard let creds = s.credentials,
+ !instances.contains(where: { $0.origin == s.url }) else { continue }
+ instances.append(SavedInstance(
+ origin: s.url, deviceToken: creds.token,
+ screenName: creds.name ?? s.name ?? "",
+ userPub: creds.userPub, userPriv: creds.userPriv,
+ groupName: s.name ?? "",
+ starred: s.isStarred ? true : nil,
+ archived: s.isArchived ? true : nil))
+ }
+ applyVault(servers.map {
+ (url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name)
+ })
+ }
+
/// Archive or unarchive a space: hidden from the drawer's main lists,
/// grouped under Archived. No reordering — position is kept for unarchive.
func toggleArchive(_ inst: SavedInstance) {
@@ -223,10 +244,6 @@ struct RootView: View {
// probe (EdgeTouchReporter) that observes the touch without consuming it; a
// SwiftUI zero-distance drag would do the same but swallow the list's taps.
@State private var edgeTouch = false
- @State private var showAdd = false
- // An invite link the OS opened us with (custom scheme or universal link),
- // waiting to pre-fill the add sheet.
- @State private var pendingAddLink = ""
private let drawerWidth: CGFloat = 300
// Slide-in / settle curve. easeOut decelerates into place and never
@@ -272,22 +289,6 @@ struct RootView: View {
showServers = open
drawerPos = open ? 1 : 0
}
- .sheet(isPresented: $showAdd, onDismiss: { pendingAddLink = "" }) {
- AddInstanceView(initialLink: pendingAddLink, onAdded: select)
- // Keyed on the link: a fresh OS-opened link rebuilds (and
- // re-seeds) the sheet even if it was already up.
- .id(pendingAddLink)
- }
- // The OS hands us invite links two ways: the welcome page's
- // elevenmessenger://add?url=<link> and universal links (the raw https
- // invite). Either routes into the add sheet pre-filled, which runs the
- // normal validate → JoinWebView flow.
- .onOpenURL { url in
- guard let raw = inviteLink(fromOpenedURL: url), parseJoinLink(raw) != nil else { return }
- pendingAddLink = raw
- showServers = false
- showAdd = true
- }
.onAppear {
if nav.instanceID == nil { nav.selectInstance(rememberedInstance) }
// Someone with chats wants to hear about them (no-op without the
@@ -296,6 +297,7 @@ struct RootView: View {
// A cold-launch notification tap is already waiting here (delivered
// before this view could subscribe) — route it now.
routeNotificationTap()
+ pullHome()
routeChangelogTap()
}
// Sign-in restore populates instances while the first-run screen is
@@ -316,6 +318,9 @@ struct RootView: View {
// appear / server switch, so foregrounding otherwise never refreshed.
.onChange(of: scenePhase) { _, phase in
refreshInbox(when: phase == .active)
+ // Foregrounding also pulls the vault: a star/archive made on the
+ // home page (or another device) lands here without a re-sign-in.
+ if phase == .active { pullHome() }
// Leaving for the background arms the next Background App Refresh
// wake, so chats keep syncing into the local store while we sleep.
if phase == .background, !app.instances.isEmpty { BackgroundRefresh.schedule() }
@@ -331,6 +336,13 @@ struct RootView: View {
return app.instances.first { $0.id == last } ?? app.instances.first
}
+ // The silent vault pull (no-op when signed out or rate-limited).
+ private func pullHome() {
+ Task {
+ if let contents = await HomeSync.pull() { app.merge(vault: contents.servers) }
+ }
+ }
+
// Re-fetch the active server's thread list, unless one is already in flight (the
// cold-launch `.task` load, a pull-to-refresh, or an overlapping trigger).
private func refreshInbox(when: Bool) {
@@ -396,10 +408,6 @@ struct RootView: View {
activeID: nav.instanceID,
signIn: signIn,
onSelect: select,
- onAdd: {
- showServers = false
- showAdd = true
- },
onSignOut: {
// Sign out = clear this device (the spaces live in the
// member's Eleven account; signing back in restores them).
@@ -470,22 +478,19 @@ struct RootView: View {
return showServers || v.startLocation.x < 44
}
- // First run leads with "Sign in with Eleven" (restore your spaces from a
- // passkey backup); joining a specific chat by invite link is the secondary
- // path (opens the existing paste/QR sheet). The passkey ceremony is
- // iOS 18-only (PRF), so iOS 17 keeps the pre-sign-in card.
+ // First run: "Sign in with Eleven" is the ONLY door (Paul's call — no
+ // paste-a-link; joining lives on the web, a native join flow comes later).
+ // The ceremony is iOS 18-only (PRF), so iOS 17 says so instead.
@ViewBuilder private var noServers: some View {
if #available(iOS 18.0, *) {
- SignInFirstRun(model: signIn, onAddLink: { showAdd = true })
+ SignInFirstRun(model: signIn)
} else {
VStack(spacing: 14) {
- Image(systemName: "bubble.left.and.bubble.right")
+ Image(systemName: "person.badge.key")
.font(.system(size: 44)).foregroundStyle(.secondary)
- Text("No chats yet").font(.headline)
- Text("Add one with an invite link you were sent.")
+ Text("Sign in needs iOS 18").font(.headline)
+ Text("Update your iPhone to sign in to Eleven — or use your space in the browser meanwhile.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
- Button { showAdd = true } label: { Text("Add a chat").frame(maxWidth: 220) }
- .buttonStyle(.borderedProminent).controlSize(.large)
}
.padding()
}
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift
index 2284561..24851e0 100644
--- a/ios/LChat/Views/InstanceSidebar.swift
+++ b/ios/LChat/Views/InstanceSidebar.swift
@@ -12,7 +12,6 @@ struct ServerDrawer: View {
/// joined by invite link can start syncing its server list any time.
@ObservedObject var signIn: HomeSignInModel
var onSelect: (SavedInstance) -> Void
- var onAdd: () -> Void
// Sign out = full local reset, owned by RootView (final sync push, wipe,
// fold nav/session back to first-run).
var onSignOut: () -> Void = {}
@@ -83,15 +82,6 @@ struct ServerDrawer: View {
Divider()
- Button(action: onAdd) {
- Label("Add a chat", systemImage: "plus")
- .font(.body.weight(.medium))
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 20)
- .padding(.vertical, 16)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
if #unavailable(iOS 18.0) {
// The passkey ceremony (PRF) is iOS 18-only — no sign-in
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift
deleted file mode 100644
index 75aef00..0000000
--- a/ios/LChat/Views/JoinView.swift
+++ /dev/null
@@ -1,414 +0,0 @@
-import LChatCore
-import SwiftUI
-import WebKit
-import VisionKit
-import AVFoundation
-
-// Add a workspace: paste a new-format invite link (`https://…/join/<slug>#<T>`), then
-// let the web join flow do the keypair work. The join runs in a one-shot WKWebView
-// (join.js): it derives the invite secret, generates the user keypair (or unwraps a
-// shipped one for an add-device link), registers the public key and claims a device
-// token — then hands {token, name, userPub, userPriv} back to us via the bridge. We
-// store the private key in the Keychain (SavedInstance) and never send it anywhere.
-//
-// Keeping join in the web layer means the keypair + invite crypto lives in one place
-// (crypto.js); native only persists the result.
-// Language + behaviour match the Mac app's AddChatView: it's "Add a chat" (never
-// "workspace"), and the link is added as soon as the pasted text is valid — no
-// button to press.
-struct AddInstanceView: View {
- @EnvironmentObject var app: AppState
- @Environment(\.dismiss) private var dismiss
-
- @State private var link = ""
- @State private var joinURL: URL?
- @State private var origin = ""
- @State private var error: String?
- @State private var pending: Task<Void, Never>?
- // A valid link resolves to a branded confirmation ("You're joining <Name>",
- // with the server's photo when it has one) before the web join flow opens.
- @State private var confirming = false
- @State private var serverName = ""
- @State private var serverPhoto: Data?
- @State private var brandingTask: Task<Void, Never>?
- @State private var showScanner = false
-
- // A link handed to us by the OS (custom scheme / universal link) — seeded into
- // the paste field on appear so it runs the exact same validate/debounce/start
- // path as a manual paste.
- var initialLink: String = ""
-
- // Called after the new chat is added, so the caller can select it (and show
- // its threads straight away).
- var onAdded: (SavedInstance) -> Void = { _ in }
-
- var body: some View {
- NavigationStack {
- Group {
- if let joinURL {
- JoinWebView(url: joinURL, onJoined: complete, onError: { msg in
- error = msg
- self.joinURL = nil
- })
- } else if confirming {
- confirmCard
- } else {
- pasteForm
- }
- }
- .navigationTitle("Add a chat")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
- }
- }
- // Assigning fires the field's onChange, so an OS-opened link takes the
- // same debounce → start() route as a paste (no second code path).
- .onAppear {
- if link.isEmpty, !initialLink.isEmpty { link = initialLink }
- }
- }
-
- private var pasteForm: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- VStack(alignment: .leading, spacing: 10) {
- step(1, "Ask for an invite link, or in an existing chat open the **⋮** menu and choose **Add a device**.")
- step(2, "Paste the link here — it's added as soon as it's valid.")
- }
-
- TextField("https://…/join/…", text: $link, axis: .vertical)
- .textFieldStyle(.roundedBorder)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .keyboardType(.URL)
- .onChange(of: link) { _, new in
- // Auto-start once the text is a valid link — but debounced:
- // a fragment is "valid" from its first character, so starting
- // on the spot mid-typing would fire with a truncated secret.
- pending?.cancel()
- guard parseJoinLink(new) != nil else { return }
- pending = Task {
- try? await Task.sleep(for: .milliseconds(500))
- if !Task.isCancelled { prepareConfirm() }
- }
- }
-
- if let error {
- Text(error).font(.callout).foregroundStyle(.red)
- } else if !link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- // Only nag once they've typed something that isn't (yet) a link.
- Label("That doesn't look like an invite link yet.", systemImage: "exclamationmark.circle")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- if DataScannerViewController.isSupported {
- Button {
- error = nil
- showScanner = true
- } label: {
- Label("Scan QR code", systemImage: "qrcode.viewfinder")
- }
- .buttonStyle(.bordered)
- }
- }
- .padding()
- }
- .fullScreenCover(isPresented: $showScanner) {
- QRScanSheet(onScanned: handleScanned)
- }
- }
-
- // A scanned QR code carries the same invite link a paste would — route it
- // through the identical validation/start path so both entry points behave alike.
- private func handleScanned(_ payload: String) {
- link = payload
- guard parseJoinLink(payload) != nil else {
- error = "That QR code doesn't look like an invite link."
- return
- }
- prepareConfirm()
- }
-
- private func step(_ n: Int, _ markdown: String) -> some View {
- HStack(alignment: .firstTextBaseline, spacing: 8) {
- Text("\(n).")
- .font(.callout.monospacedDigit())
- .foregroundStyle(.tertiary)
- Text((try? AttributedString(markdown: markdown)) ?? AttributedString(markdown))
- .font(.callout)
- .foregroundStyle(.secondary)
- Spacer(minLength: 0)
- }
- }
-
- // A valid link → a branded confirmation card. The card is populated instantly
- // (host as a placeholder name) and enriched with the real name + photo when the
- // public branding fetch returns — a failed fetch just leaves the placeholder,
- // so an unreachable/plain instance can still be joined.
- private func prepareConfirm() {
- error = nil
- guard let parsed = parseJoinLink(link), let url = URL(string: parsed.origin) else { return }
- origin = parsed.origin
- serverName = url.host ?? parsed.origin
- serverPhoto = nil
- confirming = true
- brandingTask?.cancel()
- brandingTask = Task {
- let api = LChatAPI(baseURL: url, token: "")
- let info = await api.serverInfo()
- if Task.isCancelled { return }
- serverName = info.name
- if info.hasAvatar {
- let data = await api.serverAvatar()
- if Task.isCancelled { return }
- serverPhoto = data
- }
- }
- }
-
- private var confirmCard: some View {
- VStack(spacing: 16) {
- Spacer(minLength: 0)
- serverBadge
- Text("You're joining").font(.callout).foregroundStyle(.secondary)
- Text(serverName).font(.title2.weight(.semibold)).multilineTextAlignment(.center)
- Button {
- start() // proceed to the web join flow (pick a screen name)
- } label: {
- Text("Join").frame(maxWidth: .infinity)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- Button("Use a different link") {
- brandingTask?.cancel()
- confirming = false
- serverPhoto = nil
- link = ""
- }
- .buttonStyle(.plain)
- .foregroundStyle(.secondary)
- Spacer(minLength: 0)
- }
- .padding(24)
- }
-
- @ViewBuilder private var serverBadge: some View {
- if let data = serverPhoto, let ui = UIImage(data: data) {
- Image(uiImage: ui).resizable().scaledToFill()
- .frame(width: 84, height: 84)
- .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
- } else {
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(Color.accentColor.opacity(0.15))
- .frame(width: 84, height: 84)
- .overlay(
- Text(firstLetter(serverName))
- .font(.largeTitle.weight(.semibold))
- .foregroundStyle(Color.accentColor)
- )
- }
- }
-
- private func firstLetter(_ s: String) -> String {
- s.first.map { String($0).uppercased() } ?? "?"
- }
-
- private func start() {
- error = nil
- guard let parsed = parseJoinLink(link) else { return }
- origin = parsed.origin
- joinURL = parsed.joinURL
- }
-
- private func complete(_ joined: JoinedIdentity) {
- let inst = SavedInstance(
- origin: origin,
- deviceToken: joined.token,
- screenName: joined.name,
- userPub: joined.userPub,
- userPriv: joined.userPriv
- )
- app.add(inst)
- onAdded(inst)
- dismiss()
- }
-}
-
-// The fields the web join flow hands back.
-struct JoinedIdentity {
- let token: String
- let name: String
- let userPub: String
- let userPriv: String
-}
-
-// parseJoinLink (validation + origin extraction) lives in InviteLink.swift,
-// shared with the Mac target and the standalone parser test.
-
-// Hosts join.js with window.__lchatEmbed set, so it posts the resolved identity back
-// on the "lchat" handler instead of navigating to "/".
-struct JoinWebView: UIViewRepresentable {
- let url: URL
- let onJoined: (JoinedIdentity) -> Void
- let onError: (String) -> Void
-
- func makeCoordinator() -> Coordinator { Coordinator(onJoined: onJoined, onError: onError) }
-
- func makeUIView(context: Context) -> WKWebView {
- let cfg = WKWebViewConfiguration()
- // One-shot webview: never inherit (or leave behind) web sessions. With the
- // shared default store, a previous join's localStorage session makes join.js
- // take its "already a member on this device" shortcut and silently consume
- // the invite as that old identity — the native shell owns identities, not
- // the webview.
- cfg.websiteDataStore = .nonPersistent()
- let flag = WKUserScript(source: "window.__lchatEmbed = true;",
- injectionTime: .atDocumentStart, forMainFrameOnly: true)
- cfg.userContentController.addUserScript(flag)
- cfg.userContentController.add(context.coordinator, name: "lchat")
- let web = WKWebView(frame: .zero, configuration: cfg)
- web.navigationDelegate = context.coordinator
- #if DEBUG
- if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true }
- #endif
- web.load(URLRequest(url: url))
- return web
- }
-
- func updateUIView(_ web: WKWebView, context: Context) {}
-
- final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
- let onJoined: (JoinedIdentity) -> Void
- let onError: (String) -> Void
- private var done = false
-
- init(onJoined: @escaping (JoinedIdentity) -> Void, onError: @escaping (String) -> Void) {
- self.onJoined = onJoined
- self.onError = onError
- }
-
- func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
- guard !done, message.name == "lchat",
- let body = message.body as? [String: Any],
- let j = body["joined"] as? [String: Any],
- let token = j["token"] as? String,
- let name = j["name"] as? String,
- let pub = j["userPub"] as? String,
- let priv = j["userPriv"] as? String
- else { return }
- done = true
- onJoined(JoinedIdentity(token: token, name: name, userPub: pub, userPriv: priv))
- }
- }
-}
-
-// Full-screen camera view presented from AddInstanceView's "Scan QR code" button.
-// Wraps the scanner with a Cancel bar since DataScannerViewController has no chrome
-// of its own. Camera authorization is requested up front — DataScannerViewController
-// doesn't reliably surface the system permission prompt itself, and silently starting
-// it pre-authorization just shows a dead, empty camera view.
-private struct QRScanSheet: View {
- let onScanned: (String) -> Void
- @Environment(\.dismiss) private var dismiss
- @State private var authorized: Bool?
-
- var body: some View {
- ZStack(alignment: .top) {
- switch authorized {
- case true:
- QRScannerView(onScanned: { payload in
- dismiss()
- onScanned(payload)
- })
- .ignoresSafeArea()
- case false:
- cameraDeniedMessage
- case nil:
- Color.black.ignoresSafeArea()
- }
-
- HStack {
- Button("Cancel") { dismiss() }
- .foregroundStyle(.white)
- Spacer()
- }
- .padding()
- .background(LinearGradient(colors: [.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom))
- }
- .task {
- switch AVCaptureDevice.authorizationStatus(for: .video) {
- case .authorized: authorized = true
- case .notDetermined: authorized = await AVCaptureDevice.requestAccess(for: .video)
- default: authorized = false
- }
- }
- }
-
- private var cameraDeniedMessage: some View {
- VStack(spacing: 12) {
- Image(systemName: "camera.fill")
- .font(.largeTitle)
- .foregroundStyle(.white)
- Text("Camera access is off")
- .font(.headline)
- .foregroundStyle(.white)
- Text("Turn it on in Settings to scan an invite QR code.")
- .font(.callout)
- .foregroundStyle(.white.opacity(0.8))
- .multilineTextAlignment(.center)
- Button("Open Settings") {
- guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
- UIApplication.shared.open(url)
- }
- .buttonStyle(.borderedProminent)
- .padding(.top, 8)
- }
- .padding(32)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color.black.ignoresSafeArea())
- }
-}
-
-// VisionKit's live camera scanner, restricted to QR payloads — we only want the
-// invite-link URL text out of the frame, not general barcode/text recognition.
-private struct QRScannerView: UIViewControllerRepresentable {
- let onScanned: (String) -> Void
-
- func makeUIViewController(context: Context) -> DataScannerViewController {
- let vc = DataScannerViewController(
- recognizedDataTypes: [.barcode(symbologies: [.qr])],
- qualityLevel: .balanced,
- isHighlightingEnabled: true
- )
- vc.delegate = context.coordinator
- return vc
- }
-
- func updateUIViewController(_ vc: DataScannerViewController, context: Context) {
- guard !vc.isScanning else { return }
- try? vc.startScanning()
- }
-
- static func dismantleUIViewController(_ vc: DataScannerViewController, coordinator: Coordinator) {
- vc.stopScanning()
- }
-
- func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) }
-
- final class Coordinator: NSObject, DataScannerViewControllerDelegate {
- let onScanned: (String) -> Void
- private var done = false
-
- init(onScanned: @escaping (String) -> Void) {
- self.onScanned = onScanned
- }
-
- func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
- guard !done, case let .barcode(barcode) = addedItems.first, let payload = barcode.payloadStringValue else { return }
- done = true
- onScanned(payload)
- }
- }
-}
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
index 3258014..4de35a1 100644
--- a/ios/LChat/Views/SignInWithEleven.swift
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -234,7 +234,6 @@ struct SignInFirstRun: View {
@EnvironmentObject var app: AppState
@ObservedObject var model: HomeSignInModel
/// Opens the existing invite-link / QR add sheet.
- var onAddLink: () -> Void
var body: some View {
VStack(spacing: 0) {
@@ -293,10 +292,6 @@ struct SignInFirstRun: View {
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
- Button("Add with an invite link", action: onAddLink)
- .font(.callout)
- .padding(.top, 2)
- .disabled(model.phase.busy)
}
.padding(.horizontal, 32)
.padding(.bottom, 8)
@@ -311,7 +306,7 @@ struct SignInFirstRun: View {
Label("\(n) space\(n == 1 ? "" : "s") restored.", systemImage: "checkmark.circle.fill")
.font(.callout).foregroundStyle(.green)
case .emptyBackup:
- Text("Signed in — no spaces saved yet. Add one with an invite link.")
+ Text("Signed in — nothing saved on this account yet. Join a space in your browser first.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
case .backedUp(let n):
Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill")
diff --git a/ios/LChatCore/Sources/LChatCore/HomeSync.swift b/ios/LChatCore/Sources/LChatCore/HomeSync.swift
index f65f610..5260502 100644
--- a/ios/LChatCore/Sources/LChatCore/HomeSync.swift
+++ b/ios/LChatCore/Sources/LChatCore/HomeSync.swift
@@ -67,6 +67,22 @@ public enum HomeSync {
private static var queue: Task<Void, Never>?
+ /// Silent vault fetch with the stored session (no ceremony — the DEK is
+ /// all it needs): the foreground pull that carries changes made on the
+ /// home page (or another device) INTO this one. Returns nil when signed
+ /// out, offline, or the blob is gone. Rate-limited so scene-phase churn
+ /// doesn't hammer home.
+ public static func pull(minInterval: TimeInterval = 20) async -> HomeVault.Contents? {
+ guard let session = HomeSessionStore.load(), let dek = session.dek else { return nil }
+ if let last = lastPull, Date().timeIntervalSince(last) < minInterval { return nil }
+ lastPull = Date()
+ guard let fetch = try? await HomeClient().fetchBlob(token: session.token),
+ let contents = try? HomeVault.open(fetch.bytes, dek: dek) else { return nil }
+ return contents
+ }
+
+ private static var lastPull: Date?
+
/// Upsert the device's list (order + stars + credentials) into the vault.
/// Works on the RAW JSON object and patches only the fields this build
/// owns, so anything a newer web/app build stored — unknown top-level
diff --git a/ios/LChatMac/AddChatView.swift b/ios/LChatMac/AddChatView.swift
deleted file mode 100644
index f3398e4..0000000
--- a/ios/LChatMac/AddChatView.swift
+++ /dev/null
@@ -1,174 +0,0 @@
-import LChatCore
-import SwiftUI
-
-// Add a chat by pasting an invite link. As soon as the pasted text looks like a
-// valid invite link it's added automatically — no "Add" button to press. The link
-// is loaded verbatim by the webview (its #fragment secret never leaves the device),
-// and the web join flow is where you pick your screen name.
-struct AddChatView: View {
- @EnvironmentObject var app: MacAppState
- @Environment(\.dismiss) private var dismiss
- @State private var link = ""
- @FocusState private var focused: Bool
- // A valid link resolves to a branded confirmation ("You're joining <Name>",
- // with the server's photo when it has one) before the chat is added.
- @State private var confirming = false
- @State private var serverName = ""
- @State private var serverPhoto: Data?
- @State private var brandingTask: Task<Void, Never>?
-
- var body: some View {
- Group {
- if confirming { confirmCard } else { pasteForm }
- }
- .padding(20)
- .frame(width: 460)
- .onAppear { focused = true }
- }
-
- private var pasteForm: some View {
- VStack(alignment: .leading, spacing: 16) {
- Text("Add a chat").font(.title2.weight(.semibold))
-
- VStack(alignment: .leading, spacing: 10) {
- step(1, "In an existing chat, open the **⋮** menu and choose **Add a device**.")
- step(2, "Paste the link here — you'll confirm the chat before joining.")
- }
-
- TextField("https://…/join/…", text: $link)
- .textFieldStyle(.roundedBorder)
- .focused($focused)
- .onChange(of: link) { _, new in
- if !confirming, isValidInvite(new) { prepareConfirm(new) }
- }
-
- // Only nag once they've typed something that isn't (yet) a link.
- if !link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isValidInvite(link) {
- Label("That doesn't look like an invite link yet.", systemImage: "exclamationmark.circle")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- HStack {
- Spacer()
- Button("Cancel") { dismiss() }
- .keyboardShortcut(.cancelAction)
- }
- }
- }
-
- private var confirmCard: some View {
- VStack(spacing: 16) {
- serverBadge
- Text("You're joining").font(.callout).foregroundStyle(.secondary)
- Text(serverName).font(.title2.weight(.semibold)).multilineTextAlignment(.center)
- HStack {
- Button("Use a different link") {
- brandingTask?.cancel()
- confirming = false
- serverPhoto = nil
- link = ""
- }
- .keyboardShortcut(.cancelAction)
- Spacer()
- Button("Join") {
- app.add(url: link.trimmingCharacters(in: .whitespacesAndNewlines))
- dismiss()
- }
- .keyboardShortcut(.defaultAction)
- .buttonStyle(.borderedProminent)
- }
- }
- }
-
- @ViewBuilder private var serverBadge: some View {
- if let data = serverPhoto, let ns = NSImage(data: data) {
- Image(nsImage: ns).resizable().scaledToFill()
- .frame(width: 72, height: 72)
- .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
- } else {
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(Color.accentColor.opacity(0.15))
- .frame(width: 72, height: 72)
- .overlay(
- Text(firstLetter(serverName))
- .font(.largeTitle.weight(.semibold))
- .foregroundStyle(Color.accentColor)
- )
- }
- }
-
- // A valid link → a branded confirmation. Populated instantly (host as a
- // placeholder name) and enriched with the real name + photo when the public
- // branding fetch returns; a failed fetch leaves the placeholder, so a plain or
- // unreachable instance can still be joined.
- private func prepareConfirm(_ raw: String) {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let originURL = originOf(trimmed) else { return }
- confirming = true // set first so the link reassignment below can't recurse
- link = trimmed
- serverName = originURL.host ?? trimmed
- serverPhoto = nil
- brandingTask?.cancel()
- brandingTask = Task {
- let api = LChatAPI(baseURL: originURL, token: "")
- let info = await api.serverInfo()
- if Task.isCancelled { return }
- serverName = info.name
- if info.hasAvatar {
- let data = await api.serverAvatar()
- if Task.isCancelled { return }
- serverPhoto = data
- }
- }
- }
-
- // The bare origin (scheme://host[:port]) of an invite link — where the public
- // branding endpoints live.
- private func originOf(_ raw: String) -> URL? {
- guard let c = URLComponents(string: raw), let scheme = c.scheme, let host = c.host
- else { return nil }
- var out = "\(scheme)://\(host)"
- if let port = c.port { out += ":\(port)" }
- return URL(string: out)
- }
-
- private func firstLetter(_ s: String) -> String {
- s.first.map { String($0).uppercased() } ?? "?"
- }
-
- private func step(_ n: Int, _ markdown: String) -> some View {
- HStack(alignment: .firstTextBaseline, spacing: 8) {
- Text("\(n).")
- .font(.callout.monospacedDigit())
- .foregroundStyle(.tertiary)
- Text(md(markdown))
- .font(.callout)
- .foregroundStyle(.secondary)
- Spacer(minLength: 0)
- }
- }
-
- private func md(_ s: String) -> AttributedString {
- (try? AttributedString(markdown: s)) ?? AttributedString(s)
- }
-}
-
-// A valid invite link for either format: https(s)://host/join/<token>#<secret>.
-// The secret may carry the "e1." app-hint prefix or not (older links) — both pass.
-// (Legacy links carry `#k=<key>`; new-format ones a one-time secret — we accept
-// both, so this can't rely on parseInvite, which only matches legacy `#k=`.)
-// File-scope so the app's onOpenURL handler validates with the same logic.
-func isValidInvite(_ raw: String) -> Bool {
- let s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let c = URLComponents(string: s),
- let scheme = c.scheme, scheme == "http" || scheme == "https",
- let host = c.host, !host.isEmpty,
- let frag = c.fragment, !frag.isEmpty
- else { return false }
- let segments = c.path.split(separator: "/").map(String.init)
- guard let j = segments.firstIndex(of: "join"), j + 1 < segments.count,
- !segments[j + 1].isEmpty
- else { return false }
- return true
-}
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift
deleted file mode 100644
index f0c9030..0000000
--- a/ios/LChatMac/ChatFeedWebView.swift
+++ /dev/null
@@ -1,580 +0,0 @@
-import SwiftUI
-import WebKit
-import AppKit
-import UniformTypeIdentifiers
-import UserNotifications
-
-// Hosts one chat instance's web client (chat.js) in a WKWebView — the "feed as a
-// webview" half. All rendering, the composer, the WebSocket and (crucially) every
-// bit of encryption stay in the web layer, so there is one audited crypto path and
-// the server still only ever sees ciphertext.
-//
-// Loading strategy: we load the *origin* (`/`) first, not the invite link. Once an
-// instance has been joined, its session lives in that origin's persistent
-// localStorage, so the feed renders directly. Only when there's no session yet do
-// we fall back to the one-time join link to claim it — reloading the join link on
-// every open is what produced the "this link doesn't work" error (a spent invite
-// re-claimed) when switching between servers.
-//
-// Window dragging: a WKWebView swallows mouse events, so the OS can't drag the
-// window from the web header. Instead we inject a script that detects a drag on the
-// header (not on a button) and forwards the movement to `moveWindow`.
-//
-// File drag-and-drop: macOS WKWebView doesn't reliably deliver Finder file-drops to
-// the page's DOM, so DropWebView intercepts them natively and feeds the bytes into
-// chat.js's own file input (`window.__lchatAddFiles`) — the same path a picked file
-// takes, so processing/encryption/upload all stay in the web layer.
-struct ChatFeedWebView: NSViewRepresentable {
- let chat: SavedChat
- let app: MacAppState
- let isActive: Bool
- // Re-runs updateNSView when the Text Size pref changes so the live page
- // re-renders at the new size without a reload.
- @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize
-
- func makeCoordinator() -> Coordinator { Coordinator(chat: chat, app: app) }
-
- // Drive the web feed's text size from the native pref via the page's
- // --msg-font-size variable (style.css). At the default the override is
- // REMOVED, so the web's own theme-sheet slider (a browser-local pref)
- // still rules until the Mac pref is deliberately moved.
- static func textSizeJS(_ size: Double) -> String {
- let px = Int(ChatTextSize.snap(size))
- return px == Int(ChatTextSize.defaultSize)
- ? "document.documentElement.style.removeProperty('--msg-font-size')"
- : "document.documentElement.style.setProperty('--msg-font-size','\(px)px')"
- }
-
- func makeNSView(context: Context) -> WKWebView {
- let cfg = WKWebViewConfiguration()
- cfg.websiteDataStore = .default() // persist localStorage/session per origin
- // Advertise the native wrapper in the UA too (a reusable signal), but the
- // load-bearing bit is this: tag <html>.native at document-start on EVERY
- // load/navigation/reload, from the app side. That's what makes the native
- // styling (dropped hand cursors, non-selectable header, …) survive reloads —
- // relying on the server page's own UA detection wasn't reliable.
- cfg.applicationNameForUserAgent = "lchatMac"
- cfg.userContentController.addUserScript(
- WKUserScript(source: "document.documentElement.classList.add('native')",
- injectionTime: .atDocumentStart, forMainFrameOnly: true)
- )
- cfg.userContentController.add(context.coordinator, name: "lchatDrag")
- cfg.userContentController.add(context.coordinator, name: "lchatNotify")
- cfg.userContentController.add(context.coordinator, name: "lchatTheme")
- cfg.userContentController.add(context.coordinator, name: "lchatMenu")
- cfg.userContentController.addUserScript(
- WKUserScript(source: Self.injectedScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
- )
- let web = DropWebView(frame: .zero, configuration: cfg)
- web.registerForDraggedTypes([.fileURL])
- web.onDropFiles = { [weak coordinator = context.coordinator] urls in
- coordinator?.handleDroppedFiles(urls)
- }
- web.onDragActive = { [weak coordinator = context.coordinator] active in
- coordinator?.setDragOverlay(active)
- }
- context.coordinator.webView = web
- web.uiDelegate = context.coordinator
- web.navigationDelegate = context.coordinator
- web.allowsBackForwardNavigationGestures = false // no two-finger swipe nav
- web.setValue(false, forKey: "drawsBackground") // let the page's own bg show
- #if DEBUG
- if #available(macOS 13.3, *) { web.isInspectable = true }
- #endif
- if let start = chat.origin ?? URL(string: chat.url) {
- // Hard load (like a browser shift-reload): WKWebView's subresource
- // cache can hang on to /static/*.js past the server's no-cache, so a
- // relaunch could keep running week-old app code against a redeployed
- // server. The assets are small; always fetch them fresh at launch.
- web.load(URLRequest(url: start, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
- }
- return web
- }
-
- func updateNSView(_ web: WKWebView, context: Context) {
- // Track the selected instance's webview so a dropped (or picked) file is always
- // injected into the feed you're viewing — never a hidden, stacked instance's
- // webview (which is how drops leaked to a different chat). That routing in
- // handleDroppedFiles is the sole guarantee: every webview stays registered for
- // file drags, because WKWebView wipes the view's registered drag types on each
- // navigation. Keeping only the selected one registered left dead states where
- // no view accepted the drop at all — it silently did nothing until the next
- // chat switch re-registered the visible webview.
- if isActive { app.activeWebView = web }
- web.registerForDraggedTypes([.fileURL])
- web.evaluateJavaScript(Self.textSizeJS(chatTextSize), completionHandler: nil)
- }
-
- // Runs on every page load. (1) Window drag: start a native window-drag when the
- // user presses in the header on a non-interactive element and stream the pointer
- // delta to native. (2) __lchatAddFiles: rebuild dropped files and hand them to
- // chat.js's file input so they go through the normal attachment path.
- private static let injectedScript = """
- (function () {
- if (window.__lchatInstalled) return;
- window.__lchatInstalled = true;
-
- // Kill the macOS webview rubber-band: a two-finger swipe past the edge would
- // otherwise bounce/scroll the whole viewport (there's no native API for it).
- var _oss = document.createElement('style');
- _oss.textContent = 'html,body{overscroll-behavior:none!important;}';
- (document.head || document.documentElement).appendChild(_oss);
-
- var INTERACTIVE = 'button, a, input, textarea, select, label, [role="button"], [contenteditable="true"], svg';
- function inHeader(el) { return el && el.closest && el.closest('.thread-head, .side-head'); }
- function interactive(el) { return el && el.closest && el.closest(INTERACTIVE); }
- function post(m) { try { window.webkit.messageHandlers.lchatDrag.postMessage(m); } catch (e) {} }
- document.addEventListener('mousedown', function (e) {
- if (e.button !== 0 || !inHeader(e.target) || interactive(e.target)) return;
- e.preventDefault();
- function move(ev) { post({ dx: ev.movementX, dy: ev.movementY }); }
- function up() {
- document.removeEventListener('mousemove', move, true);
- document.removeEventListener('mouseup', up, true);
- }
- document.addEventListener('mousemove', move, true);
- document.addEventListener('mouseup', up, true);
- }, true);
-
- window.__lchatAddFiles = function (items) {
- var input = document.getElementById('file-input');
- if (!input || !items || !items.length) return;
- var dt = new DataTransfer();
- for (var i = 0; i < items.length; i++) {
- var it = items[i], bin = atob(it.b64), bytes = new Uint8Array(bin.length);
- for (var j = 0; j < bin.length; j++) bytes[j] = bin.charCodeAt(j);
- dt.items.add(new File([bytes], it.name, { type: it.type }));
- }
- input.files = dt.files;
- input.dispatchEvent(new Event('change', { bubbles: true }));
- };
-
- // Report the web feed's appearance CHOICE to native so the app chrome (rail,
- // palette, title bar) matches. chat.js sets color-scheme on <html> to 'light',
- // 'dark', or 'light dark' (follow OS). Crucially we report the mode, never a
- // resolved boolean: inside a WKWebView, prefers-color-scheme reflects
- // NSApp.appearance — so resolving 'system' here reads back whatever native
- // last pinned, and a forced dark could never release to the real OS setting.
- function effectiveMode() {
- var cs = document.documentElement.style.colorScheme || '';
- if (cs.indexOf('dark') !== -1 && cs.indexOf('light') === -1) return 'dark';
- if (cs.indexOf('light') !== -1 && cs.indexOf('dark') === -1) return 'light';
- return 'system';
- }
- function reportTheme() {
- try { window.webkit.messageHandlers.lchatTheme.postMessage({ mode: effectiveMode() }); } catch (e) {}
- }
- new MutationObserver(reportTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'data-theme'] });
- reportTheme();
- })();
- """
-
- final class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandler, WKDownloadDelegate {
- private let chat: SavedChat
- private let app: MacAppState
- private var didAttemptJoin = false
- weak var webView: WKWebView?
- // Where each in-flight download is being written, so we can reveal it in
- // Finder on finish (WKDownload doesn't hand the final URL back to didFinish).
- private var downloadDestinations: [WKDownload: URL] = [:]
-
- init(chat: SavedChat, app: MacAppState) {
- self.chat = chat
- self.app = app
- super.init()
- NotificationCenter.default.addObserver(
- self, selector: #selector(appDidBecomeActive),
- name: NSApplication.didBecomeActiveNotification, object: nil)
- }
-
- deinit { NotificationCenter.default.removeObserver(self) }
-
- // When Eleven regains focus, drop the caret into the selected instance's
- // message box so you can start typing immediately. Only the active instance
- // reacts, and only when a composer is present.
- @objc private func appDidBecomeActive() {
- guard app.selection == chat.id else { return }
- focusComposer()
- }
-
- // Put the caret in this instance's message box so you can type right away —
- // used on app re-focus and after adding a file. Routes through shell.js's
- // window.lchatFocusComposer, which targets the live (id-less) chat.js
- // composer and no-ops on touch / when another field is focused. No-op when
- // no composer is present.
- func focusComposer() {
- guard let web = app.activeWebView ?? webView else { return }
- DispatchQueue.main.async { [app] in
- // Never steal the keyboard from the ⌘K palette: re-activating the
- // app (or finishing a file pick) while it's open must leave the
- // palette driving — making the webview first responder here is
- // exactly what knocked its field out.
- guard !app.showPalette else { return }
- web.window?.makeFirstResponder(web)
- web.evaluateJavaScript(
- "window.lchatFocusComposer && window.lchatFocusComposer()",
- completionHandler: nil)
- }
- }
-
- // Read dropped files (non-sandboxed, so plain reads work) and hand them to
- // chat.js's file input. Base64 crosses the JS bridge safely.
- func handleDroppedFiles(_ urls: [URL]) {
- // Route to the SELECTED instance's webview: whichever hidden webview's
- // NSView caught the Finder drop, the file must land in the feed you're
- // viewing — not a different chat.
- guard let web = app.activeWebView ?? webView else { return }
- var items: [[String: String]] = []
- for url in urls {
- guard let data = try? Data(contentsOf: url) else { continue }
- let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
- items.append(["b64": data.base64EncodedString(), "name": url.lastPathComponent, "type": type])
- }
- guard !items.isEmpty,
- let json = try? JSONSerialization.data(withJSONObject: items),
- let arg = String(data: json, encoding: .utf8) else { return }
- web.evaluateJavaScript("window.__lchatAddFiles && window.__lchatAddFiles(\(arg))", completionHandler: nil)
- focusComposer() // after a drop, focus the message box so you can add a caption
- }
-
- // Show/hide the page's .drop-overlay hover state during a Finder drag. The
- // native layer intercepts the drag (a WKWebView won't surface it to the
- // page), so the page's own dragenter/leave never fire — drive it from here.
- // Routed to the selected instance's webview, like handleDroppedFiles, so the
- // hover state shows on the chat you're viewing.
- func setDragOverlay(_ active: Bool) {
- guard let web = app.activeWebView ?? webView else { return }
- web.evaluateJavaScript("window.__lchatDropOverlay && window.__lchatDropOverlay(\(active))", completionHandler: nil)
- }
-
- func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
- switch message.name {
- case "lchatDrag": moveWindow(message)
- case "lchatNotify": showNotification(message)
- case "lchatMenu": showNativeMenu(message)
- case "lchatTheme":
- if let body = message.body as? [String: Any], let mode = body["mode"] as? String {
- app.setAppearance(mode: mode, for: chat.id)
- }
- default: break
- }
- }
-
- // Move the window by the pointer delta the injected header-drag reports.
- private func moveWindow(_ message: WKScriptMessage) {
- guard let body = message.body as? [String: Any], let window = webView?.window else { return }
- let dx = (body["dx"] as? Double) ?? 0
- let dy = (body["dy"] as? Double) ?? 0
- guard dx != 0 || dy != 0 else { return }
- var origin = window.frame.origin
- origin.x += dx
- origin.y -= dy // web y grows downward; AppKit's grows upward
- window.setFrameOrigin(origin)
- }
-
- // Post a local (non-APNs) notification for a message chat.js decrypted. The
- // web layer already suppressed your own messages + the chat you're reading;
- // we just display it, tagged with this instance so a tap can jump back.
- private func showNotification(_ message: WKScriptMessage) {
- guard let body = message.body as? [String: Any] else { return }
- let content = UNMutableNotificationContent()
- content.title = (body["title"] as? String) ?? "New message"
- content.body = (body["body"] as? String) ?? ""
- content.sound = .default
- content.userInfo = ["instance": chat.id, "thread": (body["thread"] as? String) ?? ""]
- UNUserNotificationCenter.current().add(
- UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
- )
- }
-
- // Render a web kebab dropdown as a real NSMenu. chat.js posts the visible
- // items (mirroring its own DOM menu) plus the button's rect; we pop up a
- // native menu and, on selection, call back so the web layer clicks the
- // real item — one source of truth for what each option does. A `.danger`
- // item (Leave / Delete) gets a leading separator, the native idiom for a
- // destructive action set off from the rest.
- private func showNativeMenu(_ message: WKScriptMessage) {
- guard let web = message.webView ?? webView,
- let body = message.body as? [String: Any],
- let token = body["token"] as? Int,
- let rawItems = body["items"] as? [[String: Any]] else { return }
- let menu = NSMenu()
- menu.autoenablesItems = false
- for raw in rawItems {
- guard let id = raw["id"] as? String, let label = raw["label"] as? String else { continue }
- if (raw["danger"] as? Bool) == true, let last = menu.items.last, !last.isSeparatorItem {
- menu.addItem(.separator())
- }
- let item = NSMenuItem(title: label, action: #selector(nativeMenuPicked(_:)), keyEquivalent: "")
- item.target = self
- item.representedObject = ["token": token, "id": id, "web": web]
- menu.addItem(item)
- }
- guard !menu.items.isEmpty else { return }
- // Drop the menu from the button's bottom-left. The web rect is
- // top-left origin / y-down; convert into the webview's coordinate
- // space (which may or may not be flipped).
- let anchor = body["anchor"] as? [String: Any]
- let x = (anchor?["x"] as? Double) ?? 0
- let bottom = ((anchor?["y"] as? Double) ?? 0) + ((anchor?["h"] as? Double) ?? 0)
- let point = NSPoint(x: x, y: web.isFlipped ? bottom : web.bounds.height - bottom)
- // popUp blocks until the menu closes; the item action (if any) has
- // fired by then. The trailing null-pick clears the pending token on
- // dismissal, and is a no-op if an item was already picked.
- menu.popUp(positioning: nil, at: point, in: web)
- web.evaluateJavaScript("window.__lchatMenuPick && window.__lchatMenuPick(\(token), null)", completionHandler: nil)
- }
-
- @objc private func nativeMenuPicked(_ sender: NSMenuItem) {
- guard let info = sender.representedObject as? [String: Any],
- let token = info["token"] as? Int,
- let id = info["id"] as? String,
- let web = info["web"] as? WKWebView else { return }
- let safeID = id.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "'", with: "\\'")
- web.evaluateJavaScript("window.__lchatMenuPick && window.__lchatMenuPick(\(token), '\(safeID)')", completionHandler: nil)
- }
-
- // After the origin loads: a stored session means we're signed in — extract
- // the identity natively (below). No session → claim the invite once via the
- // join link, guarded so we never loop or re-claim.
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- // WKWebView clobbers the view's registered drag types during navigation,
- // so re-assert file-drop registration after every page load.
- webView.registerForDraggedTypes([.fileURL])
- // A navigation wipes inline styles — re-apply the text-size pref.
- webView.evaluateJavaScript(ChatFeedWebView.textSizeJS(ChatTextSize.current),
- completionHandler: nil)
- // Only the instance's OWN origin counts — for the join fallback AND the
- // identity extraction. The page can navigate to another origin (a link,
- // or the passkey-backup flow visiting the home server): "no session" on
- // a foreign origin must NEVER replay the one-time invite (that's what
- // produced a spurious "this invite was just used" after a backup
- // redirect), and a foreign session must never be stored as this chat's.
- if let cur = webView.url, let base = chat.origin,
- !(cur.scheme == base.scheme && cur.host == base.host && cur.port == base.port) {
- return
- }
- webView.evaluateJavaScript("localStorage.getItem('lchat_session')") { [weak self] result, _ in
- guard let self else { return }
- if let raw = result as? String, !raw.isEmpty {
- // Runs on every own-origin load — noteIdentity ignores an
- // unchanged session, and a fresh join's session (written just
- // before join.js navigates home) is picked up right here.
- self.extractIdentity(raw)
- } else if !self.didAttemptJoin, let joinURL = URL(string: self.chat.url) {
- self.didAttemptJoin = true
- webView.load(URLRequest(url: joinURL))
- }
- }
- }
-
- // The webview's session IS the identity: join.js stores {token, name,
- // userPub, userPriv} — exactly a SavedInstance minus the origin. Park it
- // in the native store (Keychain) so the native layer holds the same keys
- // the page uses; nothing is re-keyed and the key still never leaves the
- // device. This is the whole Mac "migration": every already-joined chat
- // passes through here the first time its feed loads.
- private func extractIdentity(_ sessionJSON: String) {
- struct WebSession: Decodable {
- let token: String
- let name: String
- let userPub: String
- let userPriv: String
- }
- guard let s = try? JSONDecoder().decode(WebSession.self, from: Data(sessionJSON.utf8)),
- let origin = chat.origin?.absoluteString else { return }
- // evaluateJavaScript completions arrive on the main thread; app state
- // is main-thread-owned, so this is a plain call.
- app.noteIdentity(origin: origin, token: s.token, name: s.name,
- userPub: s.userPub, userPriv: s.userPriv)
- }
-
- // macOS WKWebView needs this hook or the composer's "+" file picker does
- // nothing (unlike iOS).
- func webView(_ webView: WKWebView,
- runOpenPanelWith parameters: WKOpenPanelParameters,
- initiatedByFrame frame: WKFrameInfo,
- completionHandler: @escaping ([URL]?) -> Void) {
- let panel = NSOpenPanel()
- panel.canChooseFiles = true
- panel.canChooseDirectories = parameters.allowsDirectories
- panel.allowsMultipleSelection = parameters.allowsMultipleSelection
- panel.begin { [weak self] response in
- completionHandler(response == .OK ? panel.urls : nil)
- if response == .OK { self?.focusComposer() }
- }
- }
-
- // WKUIDelegate shows nothing for window.confirm()/alert() unless we implement
- // these — chat.js's "Cancel invite", "Remove from group", "Leave group", "Sign
- // out", and delete-message confirms all silently no-op without them. Use a
- // sheet, not NSAlert.runModal(): confirm()/alert() block the page's JS thread
- // until completionHandler fires, and a nested runModal loop risks the same
- // webview freeze the notifyError() comment in chat.js warns about.
- func webView(_ webView: WKWebView,
- runJavaScriptConfirmPanelWithMessage message: String,
- initiatedByFrame frame: WKFrameInfo,
- completionHandler: @escaping (Bool) -> Void) {
- let alert = NSAlert()
- alert.messageText = message
- alert.addButton(withTitle: "OK")
- alert.addButton(withTitle: "Cancel")
- if let window = webView.window {
- alert.beginSheetModal(for: window) { completionHandler($0 == .alertFirstButtonReturn) }
- } else {
- completionHandler(alert.runModal() == .alertFirstButtonReturn)
- }
- }
-
- // Same gap as runJavaScriptConfirmPanelWithMessage above, for window.alert() —
- // on Mac chat.js's `embed` is always nil, so notifyError() and upload-error
- // messages call plain alert() rather than posting to the native shell.
- func webView(_ webView: WKWebView,
- runJavaScriptAlertPanelWithMessage message: String,
- initiatedByFrame frame: WKFrameInfo,
- completionHandler: @escaping () -> Void) {
- let alert = NSAlert()
- alert.messageText = message
- alert.addButton(withTitle: "OK")
- if let window = webView.window {
- alert.beginSheetModal(for: window) { _ in completionHandler() }
- } else {
- alert.runModal()
- completionHandler()
- }
- }
-
- // Message links and link-preview cards render as target="_blank" anchors
- // (chat.js), which WebKit routes here as a new-window request rather than a
- // normal navigation. With no WKUIDelegate handling it, the click did nothing.
- // Hand it to the OS default browser instead of ever creating 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
- }
-
- // A file chip (chat.js) and the lightbox's "Save Image" both render as
- // download-attributed anchors pointing at an in-page blob: URL. WKWebView
- // routes those here with shouldPerformDownload set; without turning them into
- // a real WKDownload the click silently does nothing (there's no download UI at
- // all on macOS). Everything else — the join redirect, in-app navigation — is a
- // normal load and passes through.
- func webView(_ webView: WKWebView,
- decidePolicyFor navigationAction: WKNavigationAction,
- decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
- decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
- }
-
- func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
- download.delegate = self
- }
-
- func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
- download.delegate = self
- }
-
- // Save straight to ~/Downloads (Safari-like), matching what a click does in the
- // web app — no per-file save panel. WebKit fails the download if the URL already
- // exists, so a colliding name gets " 2", " 3", … appended (as Finder would).
- func download(_ download: WKDownload,
- decideDestinationUsing response: URLResponse,
- suggestedFilename: String,
- completionHandler: @escaping (URL?) -> Void) {
- let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
- ?? FileManager.default.temporaryDirectory
- let dest = Self.uniqueDestination(in: dir, filename: suggestedFilename)
- downloadDestinations[download] = dest
- completionHandler(dest)
- }
-
- // No download shelf in the app, so reveal the saved file in Finder — otherwise a
- // silent write leaves no sign the download worked.
- func downloadDidFinish(_ download: WKDownload) {
- guard let url = downloadDestinations.removeValue(forKey: download) else { return }
- NSWorkspace.shared.activateFileViewerSelecting([url])
- }
-
- func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
- downloadDestinations.removeValue(forKey: download)
- let alert = NSAlert()
- alert.messageText = "Download failed"
- alert.informativeText = error.localizedDescription
- alert.addButton(withTitle: "OK")
- if let window = webView?.window {
- alert.beginSheetModal(for: window, completionHandler: nil)
- } else {
- alert.runModal()
- }
- }
-
- // A destination that doesn't yet exist (WebKit refuses to overwrite): "name.ext",
- // then "name 2.ext", "name 3.ext", … — the Finder convention.
- private static func uniqueDestination(in dir: URL, filename: String) -> URL {
- let fm = FileManager.default
- let name = filename.isEmpty ? "download" : filename
- var candidate = dir.appendingPathComponent(name)
- guard fm.fileExists(atPath: candidate.path) else { return candidate }
- let ext = (name as NSString).pathExtension
- let base = (name as NSString).deletingPathExtension
- var n = 2
- repeat {
- let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)"
- candidate = dir.appendingPathComponent(next)
- n += 1
- } while fm.fileExists(atPath: candidate.path)
- return candidate
- }
- }
-}
-
-// A WKWebView that accepts Finder file-drops itself (macOS WKWebView otherwise
-// won't surface them to the page). Non-file drags aren't registered, so the
-// webview's own behaviour is unaffected for those.
-final class DropWebView: WKWebView {
- var onDropFiles: (([URL]) -> Void)?
- // Toggled as a file drag enters/leaves so the page can show its .drop-overlay
- // hover state — WebKit swallows the drag natively, so the web dragenter/leave
- // never fire in the app (see chat.js window.__lchatDropOverlay).
- var onDragActive: ((Bool) -> Void)?
- private let fileOpts: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true]
-
- override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
- let op = fileDrag(sender)
- if op == .copy { onDragActive?(true) } // show the drop overlay while a file hovers
- return op
- }
- override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { fileDrag(sender) }
-
- // Hide the overlay whenever the drag leaves or ends without a successful drop
- // (dragged back out, or cancelled) — performDragOperation covers the drop case.
- override func draggingExited(_ sender: NSDraggingInfo?) { onDragActive?(false) }
- override func draggingEnded(_ sender: NSDraggingInfo) { onDragActive?(false) }
-
- override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
- onDragActive?(false) // drop landed — clear the hover overlay
- guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: fileOpts) as? [URL],
- !urls.isEmpty else { return false }
- onDropFiles?(urls)
- return true
- }
-
- // A chat window, not a browser: drop WebKit's navigation context-menu items
- // (Reload / Back / Forward / Open Frame) so a right-click doesn't offer them.
- // Text/link items (Copy, Look Up, spellcheck) are left intact.
- override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
- let navItems = ["Reload", "GoBack", "GoForward", "OpenFrameInNewWindow"]
- menu.items.removeAll { item in
- guard let id = item.identifier?.rawValue else { return false }
- return navItems.contains { id.contains($0) }
- }
- }
-
- private func fileDrag(_ sender: NSDraggingInfo) -> NSDragOperation {
- sender.draggingPasteboard.canReadObject(forClasses: [NSURL.self], options: fileOpts) ? .copy : NSDragOperation()
- }
-}
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index c4916e8..bd3d7dd 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -16,10 +16,10 @@ extension Logger {
// and menu bar are real AppKit-backed SwiftUI, so scrolling, the title bar and the
// menu bar feel like a Mac app instead of an iPad app in a window.
//
-// The message feed + composer are still the existing web client (chat.js) loaded
-// in a WKWebView (see ChatFeedWebView): reused as-is, so all rendering and — the
-// load-bearing part — all encryption stay in the one audited web layer. This is
-// the "native chrome around the web feed" spike, minus Catalyst.
+// Fully native: the feed, composer and crypto all run in Swift (LChatCore).
+// The only WKWebViews left are SCOPED — mini-app panels (AppThreadWebView) and
+// nothing else; the full-frame web fallback (and its paste-a-link join) is
+// gone. Entry is by signing in with Eleven; identities ride the vault.
@main
struct LChatMacApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@@ -49,23 +49,13 @@ struct LChatMacApp: App {
.environmentObject(app)
.frame(minWidth: 820, minHeight: 560)
.onAppear { appDelegate.appState = app }
- // Invite links from outside: the welcome page's
- // elevenmessenger://add?url=<link> (universal links can't
- // validate against an ad-hoc-signed app, but a pasted/clicked
- // https invite arriving here still works). add(url:) de-dupes
- // and selects, so re-opening an existing chat just jumps to it.
.onOpenURL { url in
- // The browser sign-in handoff returns here
- // (elevenmessenger://home-session#…) — see MacHomeSignIn.
+ // The browser sign-in handoff (elevenmessenger://
+ // home-session#…) — the ONLY link the Mac app accepts now:
+ // joining happens on the web, entry here is by signing in.
if MacHomeSignIn.handleCallback(url, into: app) {
NSApp.activate(ignoringOtherApps: true)
- return
}
- guard let raw = inviteLink(fromOpenedURL: url),
- isValidInvite(raw) else { return }
- app.add(url: raw.trimmingCharacters(in: .whitespacesAndNewlines))
- app.showAdd = false // an open add sheet is now moot
- NSApp.activate(ignoringOtherApps: true)
}
}
// Hide the title bar so the feed runs to the top; the web header is made
@@ -75,8 +65,6 @@ struct LChatMacApp: App {
.defaultSize(width: 1160, height: 820)
.commands {
CommandGroup(after: .newItem) {
- Button("Add Chat…") { app.showAdd = true }
- .keyboardShortcut("n", modifiers: [.command, .shift])
// A MENU shortcut, deliberately: a view-level hidden button
// lost ⌘F whenever a greedy first responder (a mini-app's
// WKWebView, a text view) swallowed the key equivalent — menu
@@ -147,6 +135,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let updaterController = SPUStandardUpdaterController(
startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
+ // Every activation pulls the vault — switching back from the browser or
+ // another Mac is exactly when a home-page change is freshest.
+ func applicationDidBecomeActive(_ notification: Notification) {
+ appState?.pullHome()
+ }
+
func applicationDidFinishLaunching(_ notification: Notification) {
// "cmd and plus": the View menu's ⌘+ equivalent strictly needs the
// shifted key (⌘⇧=), but people press ⌘= and expect bigger text —
@@ -188,7 +182,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// Mirrors the iOS AppState, trimmed to what the Mac shell needs.
final class MacAppState: ObservableObject {
@Published var chats: [SavedChat]
- @Published var showAdd = false
@Published var showPalette = false
// Fetched group names (chat id -> name), shared by the rail and the ⌘K palette.
@Published var names: [String: String] = [:]
@@ -199,17 +192,9 @@ final class MacAppState: ObservableObject {
// Chat ids, most-recently-selected first — drives the ⌘K palette order. The rail
// keeps its own manual (drag-reorderable) order in `chats`.
@Published var recent: [String] = []
- // The selected instance's webview (set by ChatFeedWebView.updateNSView). NOT
- // @Published — it's a routing handle, not UI state. A Finder drop can be caught
- // by any instance's (hidden, stacked) webview, so we always inject the file into
- // THIS one — the feed you're actually viewing — never a different chat.
- weak var activeWebView: WKWebView?
- // Native identities, keyed by origin — the same {token, name, keypair} the
- // webview keeps in its localStorage session, extracted as each feed loads
- // (ChatFeedWebView.didFinish) and parked in the Keychain (InstancesStore,
- // file fallback for unsigned dev builds). Nothing reads these yet: they're
- // the prerequisite for the native feed (docs/native-chat.md commit 4 —
- // native WebSocket + decrypt needs the private key natively).
+ // Native identities, keyed by origin ({token, name, keypair}), parked in
+ // the Keychain (InstancesStore) — arriving via the vault (sign-in +
+ // foreground pulls); everything native reads from here.
@Published private(set) var instances: [SavedInstance]
// The photo / video the lightbox is showing (nil = closed). App-level state
// because the overlay must cover the whole window, not the pane that
@@ -387,6 +372,15 @@ final class MacAppState: ObservableObject {
/// until then Mac stars live on this device only (iOS-first, deliberately).
func syncHome() { HomeSync.pushSoon(instances: instancesInRailOrder()) }
+ /// The silent vault pull (app activation): stars/archive/order made on the
+ /// home page or another device land here without a re-sign-in. applyVault
+ /// is the same vault-wins merge sign-in uses.
+ func pullHome() {
+ Task { @MainActor in
+ if let contents = await HomeSync.pull() { _ = applyVault(contents.servers) }
+ }
+ }
+
/// Sign-in merge — the vault wins: upsert each vault server's identity +
/// star, ensure a rail entry (SavedChat) exists, and apply the vault's
/// order to the rail (Mac-only chats tail). Returns how many servers were
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift
index ca678a9..2d73171 100644
--- a/ios/LChatMac/MacHomeSignIn.swift
+++ b/ios/LChatMac/MacHomeSignIn.swift
@@ -335,10 +335,6 @@ struct MacFirstRun: View {
.padding(.top, 2)
.disabled(model.phase.busy)
- Button("Add with an invite link") { app.showAdd = true }
- .buttonStyle(.link)
- .padding(.top, 2)
- .disabled(model.phase.busy)
}
.padding(.bottom, 48)
}
@@ -352,7 +348,7 @@ struct MacFirstRun: View {
Label(msg, systemImage: "exclamationmark.triangle")
.font(.footnote).foregroundStyle(.secondary)
case .emptyBackup:
- Text("Signed in — no spaces saved yet. Add one with an invite link.")
+ Text("Signed in — nothing saved on this account yet. Join a space in your browser first.")
.font(.callout).foregroundStyle(.secondary)
case .restored(let n):
// Visible only in the beat before the rail fills and RootView
diff --git a/ios/LChatMac/MacInstance.swift b/ios/LChatMac/MacInstance.swift
index 4bb1a05..e6d5c4f 100644
--- a/ios/LChatMac/MacInstance.swift
+++ b/ios/LChatMac/MacInstance.swift
@@ -1,14 +1,10 @@
import Foundation
-// The Mac app's webview-plumbing record: the invite URL the rail was seeded with
-// (plus legacy claimed fields). This is no longer the identity model — the Mac
-// now also holds the iOS-style native identity (`SavedInstance` in the Keychain
-// via LChatCore's InstancesStore), extracted from each webview's session as its
-// feed loads (ChatFeedWebView.extractIdentity; docs/native-chat.md commit 3).
-// SavedChat stays because the feed is still the full web page: it needs the
-// original invite URL for the one-shot join fallback and drives the rail order.
-// Retiring it in favour of SavedInstance is deliberately deferred to the native
-// feed work (commit 4+), when native data starts driving the UI.
+// The Mac app's RAIL record: which spaces show, in what order, plus legacy
+// fields from the webview era (the full-frame webview and its join are gone —
+// identity is `SavedInstance` in the Keychain, arriving via the vault).
+// Retiring SavedChat entirely in favour of SavedInstance is deliberate future
+// work; today it only drives rail order and carries legacy tokens.
// The persisted session for one chat. The key never leaves the device — it lives in
// the webview's own storage, not here; this just carries what the webview needs.
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index 1f4ee80..95d3657 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -1,16 +1,16 @@
import LChatCore
import SwiftUI
-// Native layout: a fixed server rail on the left, the selected instance's web feed
-// on the right. A plain HStack (not NavigationSplitView) keeps the rail always
-// visible — no collapse / "Hide Sidebar" toggle. The title bar is hidden
-// (LChatMacApp); the top of the rail is draggable via the system title-bar region,
-// and the web header is made draggable by an injected script (see ChatFeedWebView).
+// Native layout: a fixed server rail on the left, the selected instance's
+// NATIVE chat surface on the right. A plain HStack (not NavigationSplitView)
+// keeps the rail always visible — no collapse / "Hide Sidebar" toggle. The
+// title bar is hidden (LChatMacApp); the rail's top and the chat headers act
+// as the drag region (NativeChatView's window-drag monitor).
struct RootView: View {
@EnvironmentObject var app: MacAppState
- // Chats whose webview has been created. Once opened, a webview stays mounted
- // (just hidden) so switching back is instant — no reload / loader.
+ // Chats whose pane has been created. Once opened, a pane stays mounted
+ // (just hidden) so switching back is instant — models stay parked, sockets live.
@State private var activated: Set<SavedChat.ID> = []
// Drives the first-run "Sign in" ceremony (empty rail only). Settings owns
@@ -38,7 +38,6 @@ struct RootView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.allowsHitTesting(false)
}
- .sheet(isPresented: $app.showAdd) { AddChatView() }
// ⌘K quick-switch palette, drawn over the whole window (above the web feed).
.overlay { if app.showPalette { CommandPalette() } }
// The photo / video lightboxes, zooming out of their bubble over black.
@@ -64,6 +63,18 @@ struct RootView: View {
return app.instances.first { $0.origin == origin }
}
+ // A rail entry with no native identity: signing in again pulls the key
+ // from the member's Eleven account; there is deliberately no web fallback.
+ private func lockedPane(_ chat: SavedChat) -> some View {
+ ContentUnavailableView {
+ Label(app.serverName(chat), systemImage: "lock")
+ } description: {
+ Text("This space can't be unlocked on this Mac. Sign in with Eleven to bring its key back, or remove it from the rail.")
+ } actions: {
+ Button("Remove from This Mac", role: .destructive) { app.remove(chat) }
+ }
+ }
+
// Every opened instance stays alive in the ZStack; only the selected one is
// visible and interactive. A newly-selected instance is added to `activated`,
// so its webview is created once and then cached (its WebSocket stays live in
@@ -73,15 +84,16 @@ struct RootView: View {
ForEach(app.chats) { chat in
if activated.contains(chat.id) {
Group {
- // The takeover: native chat is the main surface once an
- // instance holds its native identity; the webview runs
- // it otherwise (which is also how a fresh join gets its
- // identity extracted in the first place).
+ // Native chat is the ONLY surface — the full-frame
+ // webview (and its paste-a-link join) is gone: the app
+ // is entered by signing in with Eleven, identities ride
+ // the vault. A chat without one is an out-of-sync
+ // remnant and says so, natively.
if let inst = nativeInstance(for: chat) {
NativeChatView(instance: inst, isActive: chat.id == app.selection)
.id(inst.id)
} else {
- ChatFeedWebView(chat: chat, app: app, isActive: chat.id == app.selection)
+ lockedPane(chat)
}
}
.opacity(chat.id == app.selection ? 1 : 0)
@@ -94,9 +106,9 @@ struct RootView: View {
MacFirstRun(model: firstRunSignIn)
} else if app.selected == nil {
ContentUnavailableView(
- "No chat selected",
+ "No space selected",
systemImage: "bubble.left.and.bubble.right",
- description: Text("Add a chat with the + button.")
+ description: Text("Pick a space from the rail.")
)
}
}
@@ -167,29 +179,18 @@ private struct PaletteChat: Decodable, Identifiable {
let active: Bool
}
-private struct PaletteAction: Decodable, Identifiable {
- let id: String
- let label: String
- var danger: Bool?
-}
-
private struct PaletteWebItems: Decodable {
var chats: [PaletteChat]
- var actions: [PaletteAction]
}
// One flat, keyboard-navigable list mixing all four row kinds.
private enum PaletteRow: Identifiable {
case chat(PaletteChat)
case server(SavedChat)
- case action(PaletteAction)
- case connect
var id: String {
switch self {
case .chat(let c): return "chat:" + c.id
case .server(let s): return "server:" + s.id
- case .action(let a): return "action:" + a.id
- case .connect: return "connect"
}
}
}
@@ -205,7 +206,7 @@ struct CommandPalette: View {
@EnvironmentObject var app: MacAppState
@State private var query = ""
@State private var index = 0
- @State private var web = PaletteWebItems(chats: [], actions: [])
+ @State private var web = PaletteWebItems(chats: [])
@FocusState private var focused: Bool
// Recency rank for a server (0 = most recently opened); unopened sort last.
@@ -244,9 +245,9 @@ struct CommandPalette: View {
.map(\.chat)
}
- // The full list, in section order: chats on this server (the web layer already
- // sorted them pinned-then-recent, capped when idle so servers and actions stay
- // in reach), other servers, actions, and "Connect to a new space…" pinned last.
+ // The full list, in section order: chats on this server (sorted
+ // pinned-then-recent by the publisher, capped when idle so servers stay in
+ // reach), then other servers.
private var rows: [PaletteRow] {
let q = query.filter { !$0.isWhitespace }
var out: [PaletteRow] = []
@@ -259,15 +260,6 @@ struct CommandPalette: View {
.map { .chat($0.chat) }
}
out += serverMatches(q).map { .server($0) }
- if q.isEmpty {
- out += web.actions.map { .action($0) }
- } else {
- out += web.actions
- .compactMap { a in fuzzyScore(q, a.label).map { (action: a, score: $0) } }
- .sorted { $0.score > $1.score }
- .map { .action($0.action) }
- }
- out.append(.connect)
return out
}
@@ -378,35 +370,13 @@ struct CommandPalette: View {
// Ask the selected instance's surface what it can offer. The native
// takeover publishes its threads on MacAppState (no webview to ask); the
// webview path asks lchatPaletteItems. Fails soft either way: the palette
- // still switches servers and connects new ones — just no chat rows.
+ // still switches servers — just no chat rows.
private func loadWebItems() {
- if !app.nativeChats.isEmpty {
- web = PaletteWebItems(chats: app.nativeChats.map {
- PaletteChat(id: $0.id, name: $0.title, glyph: $0.glyph, active: false)
- }, actions: [])
- return
- }
- web = PaletteWebItems(chats: [], actions: []) // never show a stale instance's rows
- guard let webView = app.activeWebView else { return }
- webView.evaluateJavaScript(
- "JSON.stringify(window.lchatPaletteItems ? window.lchatPaletteItems() : null)"
- ) { result, _ in
- guard let json = result as? String,
- let data = json.data(using: .utf8),
- let items = try? JSONDecoder().decode(PaletteWebItems.self, from: data) else { return }
- web = items
- }
- }
-
- // Post an activation back to the web layer, which runs the real handler.
- private func runWeb(_ kind: String, _ id: String) {
- guard let webView = app.activeWebView else { return }
- let safe = id
- .replacingOccurrences(of: "\\", with: "\\\\")
- .replacingOccurrences(of: "'", with: "\\'")
- webView.evaluateJavaScript(
- "window.lchatPaletteRun && window.lchatPaletteRun('\(kind)', '\(safe)')",
- completionHandler: nil)
+ // The active NativeChatView publishes its threads on MacAppState —
+ // the sole chat source now the webview bridge is gone.
+ web = PaletteWebItems(chats: app.nativeChats.map {
+ PaletteChat(id: $0.id, name: $0.title, glyph: $0.glyph, active: false)
+ })
}
private func activate() {
@@ -418,21 +388,11 @@ struct CommandPalette: View {
private func pick(_ row: PaletteRow) {
switch row {
case .chat(let c):
- if app.nativeChats.isEmpty {
- runWeb("chat", c.id)
- } else {
- app.nativeOpenThread = c.id // consumed by the active NativeChatView
- }
+ app.nativeOpenThread = c.id // consumed by the active NativeChatView
close()
case .server(let s):
app.selection = s.id
close()
- case .action(let a):
- runWeb("action", a.id)
- close()
- case .connect:
- close()
- app.showAdd = true
}
}
@@ -445,10 +405,6 @@ struct CommandPalette: View {
chatRow(c, highlighted: highlighted)
case .server(let s):
serverRow(s, initial: inits[s.id] ?? "?", highlighted: highlighted)
- case .action(let a):
- actionRow(a, highlighted: highlighted)
- case .connect:
- connectRow(highlighted: highlighted)
}
}
@@ -500,34 +456,7 @@ struct CommandPalette: View {
}
@ViewBuilder
- private func actionRow(_ a: PaletteAction, highlighted: Bool) -> some View {
- rowShell(highlighted: highlighted, action: { pick(.action(a)) }) {
- statusDot(current: false)
- ZStack {
- RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.secondary.opacity(0.18))
- Image(systemName: actionSymbol(a.id))
- .font(.system(size: 15, weight: .semibold))
- .foregroundStyle(a.danger == true ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
- }
- .frame(width: 38, height: 38)
- Text(a.label)
- .font(.system(size: 16, weight: .semibold))
- .foregroundStyle(a.danger == true ? AnyShapeStyle(.red) : AnyShapeStyle(.primary))
- }
- }
- @ViewBuilder
- private func connectRow(highlighted: Bool) -> some View {
- rowShell(highlighted: highlighted, action: { pick(.connect) }) {
- statusDot(current: false)
- ZStack {
- RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.secondary.opacity(0.18))
- Image(systemName: "plus").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary)
- }
- .frame(width: 38, height: 38)
- Text("Connect to a new space…").font(.system(size: 16, weight: .semibold)).foregroundStyle(.secondary)
- }
- }
// Chat-name initials, mirroring MacAppState.initials(): one letter for a
// single-word name, first letters of the first two words otherwise.
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift
index dd44ad8..4a66f45 100644
--- a/ios/LChatMac/Sidebar.swift
+++ b/ios/LChatMac/Sidebar.swift
@@ -115,7 +115,6 @@ struct Sidebar: View {
}
}
Divider()
- Button("Add a Chat…") { app.showAdd = true }
Button("Manage Spaces on Eleven…") {
NSWorkspace.shared.open(URL(string: "https://home.elevenmessenger.com/")!)
}