eleven / messenger Public

iOS native app: multi-thread rebuild + chat-list-first shell (builds on #ios-multithread-rebuild) (#54)

* ios: rebuild the native client for the multi-thread model

The PR #17 multi-thread rewrite (instances are workspaces of threads; identity
is a per-instance ECDH P-256 keypair; each thread's AES key is wrapped per-member
via ECIES) left the native app — which modelled one instance as one flat chat and
delegated all crypto to a webview — broken: the PR #17 chat.js also dropped the
embed hooks PR #16 had added.

Rebuild it as a native shell (workspace switcher → thread sidebar → feed) over a
webview feed, with native identity crypto:

- crypto: ECIES wrap/unwrap in CryptoKit (P-256 ECDH → HKDF-SHA256 salt-empty
  info "lchat-thread-wrap" → AES-GCM), byte-compatible with crypto.js. The user
  private key stays in the Keychain; the webview is handed only the active
  thread's key, never the private key or other threads' keys — strictly tighter
  than the old "seed the key into localStorage". Gated by a cross-implementation
  test vector (test/threadkey.test.mjs ↔ ios/LChatTests).
- web: a thread-scoped embed mode in chat.js (window.__lchatEmbed seed +
  window.lchatNative bridge), revived .native-embed CSS, and a join handback in
  join.js returning {token,name,userPub,userPriv}.
- native: SavedInstance (keypair in Keychain), ThreadStore (native unwrap +
  decrypted sidebar titles/previews/unread), the three-pane NavigationSplitView,
  add-workspace via a one-shot join webview, and native new-chat (roster picker →
  wrap thread key per member → POST /api/threads). Clean break from legacy #k=
  links and the single-session migration.

Verified: npm test (incl. the new vector), the ECIES vector both directions
(a native-wrapped key unwraps in the web client), and the Mac Catalyst build.
Not yet verified end-to-end: the live nav/feed/join behaviour needs a deploy from
this branch (web assets are go:embed'd) + a native run.

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

* ios: end-to-end XCUITest (join → thread list → send/receive) + Send a11y label

The UI test drives the real app against a live server: paste a member-invite
link, complete the web join form, land in the DM, send from the native
composer, and assert the peer's live reply renders in the webview feed. It
needs TEST_RUNNER_INVITE_LINK (plus a scripted peer replying server-side) and
skips itself otherwise. The send button also gains an accessibility label —
it was unlabelled for VoiceOver.

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

* ios: match the Mac app's add-chat language + fix join-webview session leakage

- "Add a chat" everywhere (never "workspace"), Mac-style steps, and the link
  initialises as soon as the pasted text is valid — no Continue button
  (mirrors LChatMac's AddChatView).
- The one-shot join webview now uses a non-persistent website data store, and
  join.js skips the adopt-existing shortcut + session writes in embed mode: a
  leftover webview session was silently consuming invites as the previous
  identity and dead-ending on a blank page.

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

* ios: chat-list-first navigation with a slide-in server drawer + persistence fix

Restructure the shell to the Mac app's shape translated to a phone: the main
index is the active server's chat list; the top-left server button slides in a
drawer (the Mac rail) to switch servers or add one; tapping a chat pushes its
feed. Replaces the NavigationSplitView three-pane from the earlier rebuild.

Also fix instances vanishing on relaunch: ad-hoc dev builds can't use the
data-protection keychain (SecItemAdd fails), so a failed keychain save now
falls back to a JSON file in Application Support — the Mac scaffold's
trade-off. Ship builds with real signing keep the keychain-only path.

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

* ios: iMessage-style New chat picker — tap a person to DM, New group flow, BOT-badged Helpers

The New chat sheet now lists people directly: tapping one starts the DM on the
spot (the server dedupes, so an existing DM just opens). "New group" pushes
the multi-select + optional-name flow. Agent helpers decode is_agent from the
roster and sit in their own Helpers section with a BOT badge, mirroring the
web picker.

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

* ios: debounce the add-chat auto-init so partial links can't fire a truncated secret

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

* ios: Eleven branding — the Mac app's icon (full-bleed for iOS) + display name

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

---------
Co-authored-by: James Adam <james@lazyatom.com>
Co-authored-by: Claude <noreply@anthropic.com>
Paul Campbell 6029b28d726640d0038ceea3d7d675a91d56e5d9 parent 533c7c8
25 files changed, +1626 −843
  • ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png binary
  • ios/LChat/Crypto.swift +81 −0
  • ios/LChat/Info.plist +1 −1
  • ios/LChat/Keychain.swift +40 −16
  • ios/LChat/LChatAPI.swift +64 −79
  • ios/LChat/LChatApp.swift +99 −67
  • ios/LChat/Models.swift +74 −165
  • ios/LChat/ThreadStore.swift +167 −0
  • ios/LChat/Views/Avatar.swift +32 −0
  • ios/LChat/Views/ChatListView.swift +0 −93
  • ios/LChat/Views/ChatView.swift +78 −283
  • ios/LChat/Views/InstanceSidebar.swift +76 −0
  • ios/LChat/Views/JoinView.swift +174 −101
  • ios/LChat/Views/NewChatView.swift +172 −0
  • ios/LChat/Views/ThreadListView.swift +113 −0
  • ios/LChatTests/ECIESVectorTest.swift +52 −0
  • ios/LChatTests/thread-key-vector.json +7 −0
  • ios/LChatUITests/JoinAndChatTests.swift +84 −0
  • ios/README.md +60 −26
  • ios/project.yml +22 −1
  • package.json +1 −1
  • test/threadkey.test.mjs +74 −0
  • web/static/chat.js +117 −5
  • web/static/join.js +20 −5
  • web/static/style.css +18 −0
diff --git a/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png
index 105803e..09744f5 100644
Binary files a/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/ios/LChat/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ
diff --git a/ios/LChat/Crypto.swift b/ios/LChat/Crypto.swift
index 450ec49..920c598 100644
--- a/ios/LChat/Crypto.swift
+++ b/ios/LChat/Crypto.swift
@@ -16,6 +16,81 @@ enum CryptoError: Error {
case badKey
case badBlob
case sealFailed
+ case badWrap
+}
+
+// ECIES key-unwrap, byte-compatible with web/static/crypto.js `unwrapThreadKey`.
+//
+// In the multi-thread model each thread (and the instance) has its own AES key,
+// wrapped to each member's user public key. A wrapped blob is, base64url-encoded:
+//
+// ephPub(65) || iv(12) || ciphertext||tag
+//
+// where ephPub is an ephemeral P-256 public point (uncompressed / x963). We recover
+// the AES key with our *user private key* (PKCS#8): ECDH against ephPub, then
+// HKDF-SHA256(salt = empty, info = "lchat-thread-wrap") to the same AES-GCM key the
+// web derived, then open. The web uses the same "lchat-thread-wrap" info for both
+// thread keys and the instance key, so this one path covers both.
+//
+// This is the *only* crypto duplicated from the web (everything else stays in
+// chat.js, reached through the webview). The cross-implementation test vector in
+// ios/LChatTests/thread-key-vector.json (generated by test/threadkey.test.mjs)
+// guards it against drift.
+enum ECIES {
+ private static let info = Data("lchat-thread-wrap".utf8)
+
+ /// A fresh random 256-bit AES key (for a new thread).
+ static func randomAESKey() -> Data {
+ SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
+ }
+
+ /// Wraps a raw AES key (32 bytes) to a recipient's user public key (base64url
+ /// raw/x963), producing the base64url blob `ephPub(65) || iv(12) || ct||tag` —
+ /// byte-compatible with web/static/crypto.js `wrapThreadKey`. Used when starting
+ /// a chat: the new thread key is wrapped to each member (including yourself).
+ static func wrap(rawKey: Data, recipientPubB64: String) throws -> String {
+ guard rawKey.count == 32, let recipientRaw = Data(base64urlEncoded: recipientPubB64) else {
+ throw CryptoError.badWrap
+ }
+ do {
+ let recipient = try P256.KeyAgreement.PublicKey(x963Representation: recipientRaw)
+ let eph = P256.KeyAgreement.PrivateKey()
+ let shared = try eph.sharedSecretFromKeyAgreement(with: recipient)
+ let aes = shared.hkdfDerivedSymmetricKey(
+ using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32)
+ let box = try AES.GCM.seal(rawKey, using: aes)
+ guard let combined = box.combined else { throw CryptoError.sealFailed } // iv||ct||tag
+ var out = Data(eph.publicKey.x963Representation) // 65-byte uncompressed point
+ out.append(combined)
+ return out.base64urlEncodedString()
+ } catch {
+ throw CryptoError.badWrap
+ }
+ }
+
+ /// Unwraps a wrapped key blob (base64url) with a user private key (base64url
+ /// PKCS#8), returning the 32-byte raw AES key.
+ static func unwrap(wrappedB64: String, privPkcs8B64: String) throws -> Data {
+ guard let blob = Data(base64urlEncoded: wrappedB64), blob.count > 65 + 12,
+ let pkcs8 = Data(base64urlEncoded: privPkcs8B64) else {
+ throw CryptoError.badWrap
+ }
+ let ephPub = blob.prefix(65)
+ let sealed = blob.suffix(from: blob.startIndex + 65) // iv || ciphertext || tag
+ do {
+ let priv = try P256.KeyAgreement.PrivateKey(derRepresentation: pkcs8)
+ let eph = try P256.KeyAgreement.PublicKey(x963Representation: ephPub)
+ let shared = try priv.sharedSecretFromKeyAgreement(with: eph)
+ let aes = shared.hkdfDerivedSymmetricKey(
+ using: SHA256.self, salt: Data(), sharedInfo: info, outputByteCount: 32)
+ let box = try AES.GCM.SealedBox(combined: sealed)
+ let raw = try AES.GCM.open(box, using: aes)
+ guard raw.count == 32 else { throw CryptoError.badWrap }
+ return raw
+ } catch {
+ throw CryptoError.badWrap
+ }
+ }
}
struct GroupKey {
@@ -29,6 +104,12 @@ struct GroupKey {
key = SymmetricKey(data: raw)
}
+ /// Wraps a raw 32-byte key (e.g. one just unwrapped via `ECIES.unwrap`).
+ init(rawKey: Data) throws {
+ guard rawKey.count == 32 else { throw CryptoError.badKey }
+ key = SymmetricKey(data: rawKey)
+ }
+
// MARK: JSON payloads (chat messages)
func decryptJSON<T: Decodable>(_ type: T.Type, blob: String) throws -> T {
diff --git a/ios/LChat/Info.plist b/ios/LChat/Info.plist
index 6eed0ba..346963b 100644
--- a/ios/LChat/Info.plist
+++ b/ios/LChat/Info.plist
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
- <string>lchat</string>
+ <string>Eleven</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
diff --git a/ios/LChat/Keychain.swift b/ios/LChat/Keychain.swift
index c310b2e..ea1d870 100644
--- a/ios/LChat/Keychain.swift
+++ b/ios/LChat/Keychain.swift
@@ -1,8 +1,8 @@
import Foundation
import Security
-// Keychain-backed JSON storage. The saved chats live here (not UserDefaults)
-// because their invite URLs carry the group key — the "key is load-bearing" rule
+// Keychain-backed JSON storage. The saved instances live here (not UserDefaults)
+// because each carries its user *private* key — the "key is load-bearing" rule
// from CLAUDE.md.
//
// Every query sets kSecUseDataProtectionKeychain so Mac Catalyst uses the same
@@ -30,8 +30,12 @@ enum Keychain {
return value
}
- static func save<T: Encodable>(_ value: T, account: String) {
- guard let data = try? JSONEncoder().encode(value) else { return }
+ /// Returns whether the item actually landed in the keychain — ad-hoc dev
+ /// builds can lack the entitlement the data-protection keychain requires,
+ /// in which case SecItemAdd fails and callers need a fallback.
+ @discardableResult
+ static func save<T: Encodable>(_ value: T, account: String) -> Bool {
+ guard let data = try? JSONEncoder().encode(value) else { return false }
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
@@ -42,7 +46,7 @@ enum Keychain {
var add = base
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
- SecItemAdd(add as CFDictionary, nil)
+ return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
}
static func clear(account: String) {
@@ -55,16 +59,36 @@ enum Keychain {
}
}
-// The persisted list of chats.
-enum ChatsStore {
- private static let account = "chats"
- static func load() -> [SavedChat] { Keychain.load([SavedChat].self, account: account) ?? [] }
- static func save(_ chats: [SavedChat]) { Keychain.save(chats, account: account) }
-}
+// The persisted list of instances (each with its keypair). Keychain-backed because
+// it holds the user private keys — but ad-hoc/dev builds can't always mint the
+// data-protection entitlement, so a failed keychain save falls back to a JSON file
+// in Application Support (the Mac scaffold hit the same wall and made the same
+// trade-off). A ship build with real signing takes the keychain path only.
+enum InstancesStore {
+ private static let account = "instances"
+
+ static func load() -> [SavedInstance] {
+ Keychain.load([SavedInstance].self, account: account) ?? fileLoad() ?? []
+ }
+
+ static func save(_ instances: [SavedInstance]) {
+ if Keychain.save(instances, account: account) {
+ try? FileManager.default.removeItem(at: fileURL) // don't leave a stale copy behind
+ } else if let data = try? JSONEncoder().encode(instances) {
+ try? FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+ try? data.write(to: fileURL, options: .atomic)
+ }
+ }
+
+ private static var fileURL: URL {
+ (FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? URL(fileURLWithPath: NSTemporaryDirectory()))
+ .appendingPathComponent("lchat/instances.json")
+ }
-// Legacy single-session slot, kept only to migrate old installs into the list.
-enum SessionStore {
- private static let account = "session"
- static func load() -> Session? { Keychain.load(Session.self, account: account) }
- static func clear() { Keychain.clear(account: account) }
+ private static func fileLoad() -> [SavedInstance]? {
+ guard let data = try? Data(contentsOf: fileURL) else { return nil }
+ return try? JSONDecoder().decode([SavedInstance].self, from: data)
+ }
}
diff --git a/ios/LChat/LChatAPI.swift b/ios/LChat/LChatAPI.swift
index 004e373..fe2e821 100644
--- a/ios/LChat/LChatAPI.swift
+++ b/ios/LChat/LChatAPI.swift
@@ -1,109 +1,94 @@
import Foundation
-// Thin REST client for the lchat HTTP API. Every data endpoint is token-gated;
-// the token rides in the query string exactly as the web client sends it. The
-// server only ever sees ciphertext and opaque blob ids.
+// Thin REST client for the lchat HTTP API. Every data endpoint is token-gated; the
+// device token rides in the query string (GET) or JSON body (POST), exactly as the
+// web client sends it. The server only ever sees ciphertext, opaque blob ids, and
+// wrapped key blobs it cannot open.
struct LChatAPI {
let baseURL: URL
let token: String
private let session = URLSession(configuration: .default)
- // MARK: Invite link parsing (mirrors chat.js `parse`)
+ // MARK: Identity / roster
- struct ParsedInvite {
- let baseURL: URL
- let token: String
- let keyB64: String
+ /// Registers (idempotently) this device's user public key in the roster.
+ func ensurePubkey(_ publicKey: String) async {
+ await postJSON("/api/pubkey", ["token": token, "public_key": publicKey])
}
- /// Parses `https://host/join/<token>#k=<key>` into its parts.
- static func parseInvite(_ raw: String) -> ParsedInvite? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard var comps = URLComponents(string: trimmed),
- let scheme = comps.scheme, let host = comps.host,
- scheme == "http" || scheme == "https"
- else { return nil }
-
- let token = comps.path.split(separator: "/").last.map(String.init)
- // The key lives in the fragment as `k=…` (URLComponents leaves it raw).
- var keyB64: String?
- if let frag = comps.fragment {
- for pair in frag.split(separator: "&") {
- let kv = pair.split(separator: "=", maxSplits: 1)
- if kv.first == "k", kv.count == 2 { keyB64 = String(kv[1]) }
- }
- }
- guard let token, !token.isEmpty, let keyB64, !keyB64.isEmpty else { return nil }
-
- // Reduce to a clean origin URL (scheme://host[:port]).
- comps.path = ""
- comps.query = nil
- comps.fragment = nil
- guard let base = comps.url else { return nil }
- return ParsedInvite(baseURL: base, token: token, keyB64: keyB64)
+ func roster() async -> [RosterEntry] {
+ struct Resp: Codable { let roster: [RosterEntry]? }
+ return (try? await getJSON("/api/roster", Resp.self))?.roster ?? []
}
- // MARK: Endpoints
+ // MARK: Threads / keys
- static func inviteInfo(baseURL: URL, token: String) async throws -> InviteInfo {
- let url = baseURL.appending(path: "/api/invite/\(token.urlPathEscaped)")
- let (data, _) = try await URLSession.shared.data(from: url)
- return try JSONDecoder().decode(InviteInfo.self, from: data)
+ func threads() async -> [ServerThread] {
+ struct Resp: Codable { let threads: [ServerThread]? }
+ return (try? await getJSON("/api/threads", Resp.self))?.threads ?? []
}
- static func claim(baseURL: URL, token: String) async {
- let url = baseURL.appending(path: "/api/invite/\(token.urlPathEscaped)/claim")
- var req = URLRequest(url: url)
- req.httpMethod = "POST"
- _ = try? await URLSession.shared.data(for: req)
+ /// The caller's wrapped instance key (for member avatars), or nil if not granted.
+ func instanceKeyWrapped() async -> String? {
+ struct Resp: Codable { let wrapped: String? }
+ let wrapped = (try? await getJSON("/api/instance-key", Resp.self))?.wrapped
+ return (wrapped?.isEmpty ?? true) ? nil : wrapped
}
- func groupName() async -> String {
- let url = baseURL.appending(path: "/api/group")
- guard let (data, _) = try? await session.data(from: url),
- let obj = try? JSONDecoder().decode([String: String].self, from: data),
- let name = obj["name"]
- else { return "lchat" }
- return name
- }
-
- func messages(after: Int64) async throws -> [ServerMessage] {
- var comps = URLComponents(url: baseURL.appending(path: "/api/messages"),
- resolvingAgainstBaseURL: false)!
- comps.queryItems = [
+ /// Messages in a thread after a cursor id — used to count unread (the feed
+ /// itself is rendered by the webview).
+ func messages(thread: String, after: Int64) async -> [ServerMessage] {
+ struct Resp: Codable { let messages: [ServerMessage]? }
+ let items = [
URLQueryItem(name: "token", value: token),
+ URLQueryItem(name: "thread", value: thread),
URLQueryItem(name: "after", value: String(after)),
]
- let (data, _) = try await session.data(from: comps.url!)
- struct Resp: Codable { let messages: [ServerMessage]? }
- return try JSONDecoder().decode(Resp.self, from: data).messages ?? []
+ return (try? await getJSON("/api/messages", Resp.self, query: items))?.messages ?? []
}
- /// Uploads encrypted bytes, returns the new blob id.
- func upload(_ data: Data) async throws -> String {
- var comps = URLComponents(url: baseURL.appending(path: "/api/upload"),
- resolvingAgainstBaseURL: false)!
- comps.queryItems = [URLQueryItem(name: "token", value: token)]
- var req = URLRequest(url: comps.url!)
+ /// Creates a thread (self/dm/group) with the per-member wrapped keys. Returns the
+ /// new (or, for a deduped DM, existing) thread id. `members` is [{user_id, wrapped}].
+ func createThread(kind: String, nameBlob: String, members: [[String: String]]) async -> String? {
+ struct Resp: Codable { let thread_id: String? }
+ let body: [String: Any] = ["token": token, "kind": kind, "name_blob": nameBlob, "members": members]
+ guard let url = URL(string: "/api/threads", relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
+ var req = URLRequest(url: url)
req.httpMethod = "POST"
- req.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
- let (respData, _) = try await session.upload(for: req, from: data)
- struct Resp: Codable { let id: String }
- return try JSONDecoder().decode(Resp.self, from: respData).id
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ guard let (respData, _) = try? await session.data(for: req),
+ let r = try? JSONDecoder().decode(Resp.self, from: respData) else { return nil }
+ return r.thread_id
+ }
+
+ /// The instance's display name (public; substituted into pages too).
+ func groupName() async -> String {
+ struct Resp: Codable { let name: String? }
+ return (try? await getJSON("/api/group", Resp.self, authed: false))?.name ?? "lchat"
}
- /// Downloads encrypted attachment bytes by id.
- func blob(id: String) async throws -> Data {
- var comps = URLComponents(url: baseURL.appending(path: "/api/blob/\(id.urlPathEscaped)"),
- resolvingAgainstBaseURL: false)!
- comps.queryItems = [URLQueryItem(name: "token", value: token)]
+ // MARK: Plumbing
+
+ private func getJSON<T: Decodable>(
+ _ path: String, _ type: T.Type, query: [URLQueryItem]? = nil, authed: Bool = true
+ ) async throws -> T {
+ var comps = URLComponents(url: baseURL.appending(path: path), resolvingAgainstBaseURL: false)!
+ comps.queryItems = query ?? (authed ? [URLQueryItem(name: "token", value: token)] : nil)
let (data, _) = try await session.data(from: comps.url!)
- return data
+ return try JSONDecoder().decode(T.self, from: data)
}
-}
-private extension String {
- var urlPathEscaped: String {
- addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? self
+ @discardableResult
+ private func postJSON(_ path: String, _ body: [String: Any]) async -> Bool {
+ guard let url = URL(string: path, relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: body) else { return false }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ let resp = try? await session.data(for: req)
+ return resp != nil
}
}
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 83d0c18..3492ea8 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -12,92 +12,124 @@ struct LChatApp: App {
}
}
-// Owns the persisted list of chats and which one (if any) is open.
+// Owns the persisted list of instances (workspaces). Each instance carries its own
+// keypair; the list lives in the Keychain (InstancesStore).
final class AppState: ObservableObject {
- @Published var chats: [SavedChat]
- @Published var current: SavedChat? // nil = showing the chat list
+ @Published var instances: [SavedInstance]
- init() {
- var saved = ChatsStore.load()
- // Migrate an old single-session install into the list, once.
- if saved.isEmpty, let old = SessionStore.load() {
- let url = "\(old.origin)/join/\(old.token)#k=\(old.keyB64)"
- saved = [SavedChat(url: url, name: old.name)]
- ChatsStore.save(saved)
- SessionStore.clear()
- }
- chats = saved
- }
-
- func open(_ chat: SavedChat) { current = chat }
-
- /// Back to the list. The chat's lastSeenId (so far) clears its unread badge.
- func close(lastSeenId: Int64) {
- if let cur = current, let i = chats.firstIndex(where: { $0.id == cur.id }) {
- chats[i].lastSeenId = max(chats[i].lastSeenId, lastSeenId)
- ChatsStore.save(chats)
- }
- current = nil
- }
+ init() { instances = InstancesStore.load() }
- /// Add a chat (or update the screen name if it's already saved) and open it.
- func add(url: String, name: String) {
- let chat = SavedChat(url: url, name: name)
- if let i = chats.firstIndex(where: { $0.token != nil && $0.token == chat.token }) {
- chats[i].name = name
- ChatsStore.save(chats)
- current = chats[i]
+ /// Add a new instance, or replace one with the same id (re-join).
+ func add(_ inst: SavedInstance) {
+ if let i = instances.firstIndex(where: { $0.id == inst.id }) {
+ instances[i] = inst
} else {
- chats.append(chat)
- ChatsStore.save(chats)
- current = chat
+ instances.append(inst)
}
+ InstancesStore.save(instances)
}
- func remove(_ chat: SavedChat) {
- chats.removeAll { $0.id == chat.id }
- ChatsStore.save(chats)
+ func remove(_ inst: SavedInstance) {
+ instances.removeAll { $0.id == inst.id }
+ InstancesStore.save(instances)
}
- /// Persist the session resolved by the webview's join flow (new-format,
- /// server-blind invites), so later opens skip the join screen and the title can
- /// fetch the group name. The webview already holds the live session in its
- /// localStorage; this just mirrors it natively.
- func resolveSession(token: String, key: String, name: String, for chat: SavedChat) {
- guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return }
- chats[i].claimedToken = token
- chats[i].claimedKey = key
- if !name.isEmpty { chats[i].name = name }
- ChatsStore.save(chats)
- // Refresh the open chat so the view picks up the now-resolved session
- // (without recreating the WKWebView — it's already showing the feed).
- if current?.id == chat.id { current = chats[i] }
+ /// Persist resolved fields discovered after first load (our user id, group name).
+ func note(id: String, myUserId: String? = nil, groupName: String? = nil) {
+ guard let i = instances.firstIndex(where: { $0.id == id }) else { return }
+ var changed = false
+ if let uid = myUserId, !uid.isEmpty, instances[i].myUserId != uid {
+ instances[i].myUserId = uid; changed = true
+ }
+ if let name = groupName, !name.isEmpty, instances[i].groupName != name {
+ instances[i].groupName = name; changed = true
+ }
+ if changed { InstancesStore.save(instances) }
}
+}
- func setName(_ name: String, for chat: SavedChat) {
- guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return }
- chats[i].name = name
- ChatsStore.save(chats)
- }
+// Holds the live navigation: which instance (server) is open and its ThreadStore.
+// One ThreadStore at a time, swapped when the server changes, so the chat list and
+// the feed observe the same store.
+@MainActor
+final class NavModel: ObservableObject {
+ @Published var instanceID: String?
+ @Published var store: ThreadStore?
- func markSeen(_ id: Int64, in chat: SavedChat) {
- guard let i = chats.firstIndex(where: { $0.id == chat.id }) else { return }
- if id > chats[i].lastSeenId {
- chats[i].lastSeenId = id
- ChatsStore.save(chats)
- }
+ func selectInstance(_ inst: SavedInstance?) {
+ guard inst?.id != instanceID else { return }
+ instanceID = inst?.id
+ store = inst.map { ThreadStore(instance: $0) }
}
}
+// The shell, structured like the Mac app translated to a phone: the main index is
+// the active server's chat list; the top-left server button slides in an overlay
+// (the Mac rail as a drawer) to switch servers or add one; tapping a chat pushes
+// its feed.
struct RootView: View {
@EnvironmentObject var app: AppState
+ @StateObject private var nav = NavModel()
+ @State private var path: [String] = [] // pushed thread ids
+ @State private var showServers = false
+ @State private var showAdd = false
var body: some View {
- if let current = app.current {
- ChatView(chat: current)
- .id(current.id) // rebuild the store when switching chats
- } else {
- ChatListView()
+ NavigationStack(path: $path) {
+ Group {
+ if let store = nav.store {
+ ThreadListView(store: store, path: $path, showServers: $showServers)
+ } else {
+ noServers
+ }
+ }
+ }
+ .overlay { drawer }
+ .sheet(isPresented: $showAdd) {
+ AddInstanceView(onAdded: select)
+ }
+ .onAppear {
+ if nav.instanceID == nil { nav.selectInstance(app.instances.first) }
+ }
+ }
+
+ private func select(_ inst: SavedInstance) {
+ path = [] // pushed chats belong to the previous server
+ nav.selectInstance(inst)
+ withAnimation(.easeOut(duration: 0.2)) { showServers = false }
+ }
+
+ @ViewBuilder private var drawer: some View {
+ ZStack(alignment: .leading) {
+ if showServers {
+ Color.black.opacity(0.35)
+ .ignoresSafeArea()
+ .onTapGesture { withAnimation(.easeOut(duration: 0.2)) { showServers = false } }
+ .transition(.opacity)
+ ServerDrawer(
+ activeID: nav.instanceID,
+ onSelect: select,
+ onAdd: {
+ showServers = false
+ showAdd = true
+ }
+ )
+ .transition(.move(edge: .leading))
+ }
+ }
+ .animation(.easeOut(duration: 0.22), value: showServers)
+ }
+
+ private var noServers: some View {
+ VStack(spacing: 14) {
+ Image(systemName: "bubble.left.and.bubble.right")
+ .font(.system(size: 44)).foregroundStyle(.secondary)
+ Text("No chats yet").font(.headline)
+ Text("Add one with an invite link you were sent.")
+ .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/Models.swift b/ios/LChat/Models.swift
index 0eafc02..77d4724 100644
--- a/ios/LChat/Models.swift
+++ b/ios/LChat/Models.swift
@@ -1,195 +1,104 @@
import Foundation
-// The persisted identity for one chat on this device. The key never leaves the
-// device and is stored in the Keychain (see Keychain.swift), mirroring the
-// "the key is load-bearing" rule from CLAUDE.md.
-struct Session: Codable, Equatable {
- var baseURL: URL // e.g. https://chat.example.com — derived from the invite link
- var token: String // invite token (transport auth only; NOT a decryption key)
- var keyB64: String // base64url AES-GCM key from the link's #fragment
- var name: String // screen name
-
- /// wss/ws URL for the live feed, matching baseURL's scheme.
- var socketURL: URL {
- var c = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
- c.scheme = baseURL.scheme == "https" ? "wss" : "ws"
- c.path = "/ws"
- c.queryItems = [URLQueryItem(name: "token", value: token)]
- return c.url!
- }
-
- /// The Origin header value to present on the WebSocket upgrade, so the
- /// server's same-origin check is satisfied.
- var origin: String {
- var s = baseURL.scheme.map { "\($0)://" } ?? ""
- s += baseURL.host ?? ""
- if let port = baseURL.port { s += ":\(port)" }
- return s
- }
+// A saved instance (workspace) on this device. Identity is per-instance: each
+// instance gets its own ECDH P-256 keypair. The PRIVATE key is the load-bearing
+// secret, so the whole struct lives in the Keychain (InstancesStore) — never
+// UserDefaults. `userPriv` feeds the native ECIES unwrap (Crypto.swift) to open
+// per-thread keys for the sidebar; it is NEVER handed to a webview, which only ever
+// receives a single already-unwrapped thread key.
+struct SavedInstance: Codable, Identifiable, Equatable {
+ var origin: String // scheme://host[:port] — the instance's URL
+ var deviceToken: String // transport auth (NOT a decryption key)
+ var screenName: String // our display name in this instance
+ var userPub: String // base64url raw (uncompressed) P-256 public key
+ var userPriv: String // base64url PKCS#8 private key — Keychain only
+ var myUserId: String = "" // our roster user_id, resolved on first roster load
+ var groupName: String = ""// cached instance name, for the switcher row
+
+ // One identity per instance per device (v1). The origin is the stable handle.
+ var id: String { origin }
+
+ var baseURL: URL? { URL(string: origin) }
+
+ /// A human label for the switcher until the real group name loads.
+ var displayName: String { groupName.isEmpty ? (baseURL?.host ?? "lchat") : groupName }
}
-// A saved chat: the invite URL (which carries host, token and key in its
-// #fragment for legacy links) plus the screen name — persisted between runs.
-// lastSeenId drives the unread count shown in the chat list.
-//
-// New-format (server-blind) invite links don't carry the key — it's fetched and
-// unwrapped by the web join flow inside the WKWebView, which then hands the
-// resolved session back to us. We persist that as claimedToken/claimedKey so
-// later opens skip the join screen and the native title can fetch the group name.
-struct SavedChat: Codable, Identifiable, Equatable {
- var url: String
- var name: String
- var lastSeenId: Int64 = 0
- // Filled in once a new-format invite has been claimed via the webview. nil for
- // legacy links (whose token/key come straight from the URL).
- var claimedToken: String?
- var claimedKey: String?
-
- var id: String { url }
-
- /// The origin (scheme://host[:port]) of the stored link.
- var origin: URL? {
- guard let c = URLComponents(string: url), let scheme = c.scheme, let host = c.host else { return nil }
- var o = URLComponents()
- o.scheme = scheme
- o.host = host
- o.port = c.port
- return o.url
- }
-
- /// A usable Session, or nil if we don't have the key yet. Prefers a resolved
- /// (claimed) new-format session; otherwise parses a legacy `#k=` link.
- var session: Session? {
- if let token = claimedToken, let key = claimedKey, let base = origin {
- return Session(baseURL: base, token: token, keyB64: key, name: name)
- }
- guard let p = LChatAPI.parseInvite(url) else { return nil }
- return Session(baseURL: p.baseURL, token: p.token, keyB64: p.keyB64, name: name)
- }
-
- /// The URL the WKWebView should load: the chat origin (`/`) once we have a
- /// session to seed, otherwise the raw `/join/…` link so the web claims it.
- var loadURL: URL? { session?.baseURL ?? URL(string: url) }
-
- /// The token used to de-duplicate chats (claimed device token, else the legacy
- /// invite token from the URL).
- var token: String? { claimedToken ?? LChatAPI.parseInvite(url)?.token }
+// ---- server JSON --------------------------------------------------------
+
+// A thread as the server returns it (GET /api/threads). Opaque blobs only: the
+// server can't read name_blob/last_blob or unwrap `wrapped`.
+struct ServerThread: Codable {
+ let id: String
+ let kind: String // "self" | "dm" | "group"
+ var name_blob: String?
+ var created_at: Int64?
+ var wrapped: String? // this user's thread key, wrapped to their pubkey
+ var members: [String]?
+ var last_blob: String?
+ var last_at: Int64?
+ var avatar: String?
}
-// What /api/messages and the "msg" WebSocket frame return. Opaque to the server.
+struct RosterEntry: Codable, Equatable {
+ let user_id: String
+ let label: String
+ let public_key: String
+ var avatar: String?
+ var is_agent: Bool? // an agent/bot helper, not a person (BOT badge in pickers)
+
+ var isAgent: Bool { is_agent ?? false }
+}
+
+// GET /api/messages row (used here only to count unread; the feed is rendered by
+// the webview). Opaque to the server.
struct ServerMessage: Codable, Identifiable {
let id: Int64
let created_at: Int64
let blob: String
- var reactions: [ReactionView]?
}
-struct ReactionView: Codable, Hashable {
- let emoji: String
- let count: Int
- var mine: Bool?
-}
+// ---- decrypted payloads (native decrypts only previews + group names) ----
-// The decrypted message body — same shape chat.js builds before encrypt().
+// The decrypted message body — same shape chat.js builds before encrypt(). Native
+// only needs `name`/`text`/`attachments` for the sidebar preview line.
struct MessagePayload: Codable {
- var name: String
+ var name: String?
var text: String?
- var ts: Double? // ms since epoch (Date.now() on the web)
var attachments: [Attachment]?
- var replyTo: ReplyTo?
- var type: String? // message kind; see MessageType (nil == legacy chat)
-}
-
-// Message-content typing — mirrors web/static/message.js. The `type` rides
-// inside the encrypted payload (the server only ever sees ciphertext), so it's
-// an end-to-end signal between clients, never server metadata. Everything is a
-// "chat" message today; tagging them now lets future clients add other payload
-// kinds and lets each client decide which kinds it will render.
-enum MessageType {
- // The type this client stamps on every message it sends.
- static let chat = "chat"
-
- // A transient "is typing" presence hint. It rides its own WebSocket frame
- // (never stored, never pushed) and is handled before the feed, so it stays
- // out of `accepted` below — it must never render as a message.
- static let typing = "typing"
-
- // The types this client will render. A nil/absent type is a legacy message
- // from before payloads were tagged, so it's treated as a chat. Narrowing
- // this set — or dropping the nil allowance — is how we get "more limited".
- private static let accepted: Set<String> = [chat]
-
- // Whether a decrypted payload of the given type should be rendered.
- static func accepts(_ type: String?) -> Bool {
- guard let type else { return true }
- return accepted.contains(type)
- }
-}
-
-// A typing indicator's decrypted body — the sender's name and how long (seconds)
-// receivers should keep showing it. Encoded keys match web/static/chat.js exactly
-// (`type`/`name`/`expiry`) so the blob crosses platforms. `expiry` is integer
-// seconds; keep it Int so a peer's `15` decodes cleanly.
-struct TypingPayload: Codable {
- var type: String = MessageType.typing
- var name: String
- var expiry: Int
}
struct Attachment: Codable, Hashable {
var id: String
var name: String?
var type: String?
- var size: Int?
- var w: Int?
- var h: Int?
-
var isImage: Bool { type?.hasPrefix("image/") ?? false }
}
-struct ReplyTo: Codable, Hashable {
- var id: Int64
- var name: String?
- var snippet: String?
-}
-
-// Invite lookup result (/api/invite/{token}).
-struct InviteInfo: Codable {
- let ok: Bool
- var revoked: Bool?
- var label: String?
-}
-
-// The fixed reaction set. Copied verbatim from hub.go's allowedReactions —
-// `❤️` carries a U+FE0F variation selector; a server reaction that doesn't match
-// exactly is silently dropped.
-let kReactions = ["👍", "❤️", "😂", "😮", "😢", "🎉"]
-
-// MARK: - Reply snippet (mirrors format.js replySnippet)
-
-// Summarise who's currently typing into a subtle one-liner — mirrors
-// typingSummary in web/static/format.js so both clients read identically.
-func typingSummary(_ names: [String]) -> String {
- let list = names.filter { !$0.isEmpty }
- if list.isEmpty { return "" }
- if list.count == 1 { return "\(list[0]) is typing…" }
- if list.count == 2 { return "\(list[0]) and \(list[1]) are typing…" }
- return "Several people are typing…"
+// A group's encrypted name payload (threads.name_blob decrypts to this).
+struct GroupNamePayload: Codable { var name: String? }
+
+// ---- sidebar view model -------------------------------------------------
+
+// One row in the native thread sidebar — fully decrypted, ready to render.
+struct Thread: Identifiable, Equatable {
+ let id: String
+ let kind: String // "self" | "dm" | "group"
+ var title: String // "Notes" / the other person / the group name
+ var preview: String // last-message snippet
+ var lastAt: Int64 // seconds; drives ordering + the relative time
+ var unread: Bool
+ var members: [String]
+ var threadKeyB64: String // unwrapped — handed to the webview to render the feed
}
-func replySnippet(_ payload: MessagePayload, max: Int = 140) -> String {
- func clamp(_ s: String) -> String {
- guard s.count > max else { return s }
- let cut = s.prefix(max - 1).trimmingCharacters(in: .whitespaces)
- return cut + "…"
- }
- let text = (payload.text ?? "")
+// A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs —
+// mirrors previewText in web/static/chat.js.
+func previewText(kind: String, payload: MessagePayload) -> String {
+ var body = (payload.text ?? "")
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
- if !text.isEmpty { return clamp(text) }
- if let a = payload.attachments?.first {
- if a.isImage { return "📷 Photo" }
- return clamp("📎 " + (a.name ?? "Attachment"))
- }
- return ""
+ if body.isEmpty, let a = payload.attachments, !a.isEmpty { body = "📷 Photo" }
+ if kind == "group", let n = payload.name, !n.isEmpty { return "\(n): \(body)" }
+ return body
}
diff --git a/ios/LChat/ThreadStore.swift b/ios/LChat/ThreadStore.swift
new file mode 100644
index 0000000..1529a8d
--- /dev/null
+++ b/ios/LChat/ThreadStore.swift
@@ -0,0 +1,167 @@
+import Foundation
+import SwiftUI
+
+// The per-instance sidebar model. Fetches the thread list, unwraps each thread's
+// key *natively* (ECIES, via the user private key in the Keychain), and decrypts
+// just enough — group names + last-message previews — to render the sidebar. The
+// message feed itself is rendered by the webview, handed only the active thread's
+// already-unwrapped key (ThreadStore never gives the webview the private key).
+@MainActor
+final class ThreadStore: ObservableObject {
+ @Published var threads: [Thread] = []
+ @Published var instanceName: String = ""
+ @Published var loading = false
+ @Published var loadError: String?
+
+ @Published var roster: [RosterEntry] = [] // joined members (for the new-chat picker)
+
+ let instance: SavedInstance
+ private(set) var myUserId: String
+ private var instanceKeyB64: String? // for member avatars (optional)
+ private var rosterLabels: [String: String] = [:]
+
+ private var api: LChatAPI? {
+ guard let base = instance.baseURL else { return nil }
+ return LChatAPI(baseURL: base, token: instance.deviceToken)
+ }
+
+ init(instance: SavedInstance) {
+ self.instance = instance
+ self.myUserId = instance.myUserId
+ }
+
+ /// The unwrapped instance key (base64url), handed to the webview so it can show
+ /// member profile photos. nil until loaded / if we haven't been granted it.
+ func instanceKey() -> String? { instanceKeyB64 }
+
+ func refresh() async {
+ guard let api else { loadError = "This instance's address is invalid."; return }
+ loading = true
+ defer { loading = false }
+
+ await api.ensurePubkey(instance.userPub)
+
+ let people = await api.roster()
+ roster = people
+ rosterLabels = Dictionary(people.map { ($0.user_id, $0.label) }, uniquingKeysWith: { a, _ in a })
+ if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) {
+ myUserId = me.user_id
+ }
+
+ if let wrapped = await api.instanceKeyWrapped(),
+ let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv) {
+ instanceKeyB64 = raw.base64urlEncodedString()
+ }
+
+ instanceName = await api.groupName()
+
+ var rows: [Thread] = []
+ for t in await api.threads() {
+ // No usable key → can't decrypt anything about this thread; skip it.
+ guard let wrapped = t.wrapped, !wrapped.isEmpty,
+ let raw = try? ECIES.unwrap(wrappedB64: wrapped, privPkcs8B64: instance.userPriv)
+ else { continue }
+ let key = try? GroupKey(rawKey: raw)
+ var preview = ""
+ if let lb = t.last_blob, !lb.isEmpty, let key,
+ let p = try? key.decryptJSON(MessagePayload.self, blob: lb) {
+ preview = previewText(kind: t.kind, payload: p)
+ }
+ let lastAt = t.last_at ?? 0
+ rows.append(Thread(
+ id: t.id,
+ kind: t.kind,
+ title: threadTitle(t, key: key),
+ preview: preview,
+ lastAt: lastAt,
+ unread: lastAt > ReadState.lastSeen(instance: instance.id, thread: t.id),
+ members: t.members ?? [],
+ threadKeyB64: raw.base64urlEncodedString()
+ ))
+ }
+ rows.sort { $0.lastAt > $1.lastAt }
+ threads = rows
+ }
+
+ // Start a chat with the given other members (empty = a "Notes" self thread, one =
+ // 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.
+ func createChat(memberIDs others: [String], groupName: String?) async -> String? {
+ guard let api, !myUserId.isEmpty else {
+ loadError = "Couldn't determine your identity yet — try again in a moment."
+ return nil
+ }
+ var ids = Set(others)
+ ids.insert(myUserId)
+
+ // Public keys for everyone in the thread (ourselves from the instance keypair).
+ var pubs = [myUserId: instance.userPub]
+ for e in roster where ids.contains(e.user_id) { pubs[e.user_id] = e.public_key }
+ guard ids.allSatisfy({ pubs[$0] != nil }) else {
+ loadError = "Couldn't find a key for everyone selected."
+ return nil
+ }
+
+ let raw = ECIES.randomAESKey()
+ let kind = others.isEmpty ? "self" : (others.count == 1 ? "dm" : "group")
+ var nameBlob = ""
+ if kind == "group", let n = groupName?.trimmingCharacters(in: .whitespaces), !n.isEmpty,
+ let key = try? GroupKey(rawKey: raw) {
+ nameBlob = (try? key.encryptJSON(["name": n])) ?? ""
+ }
+
+ var members: [[String: String]] = []
+ for id in ids {
+ guard let pub = pubs[id], let wrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: pub) else {
+ loadError = "Couldn't wrap the chat key."
+ return nil
+ }
+ members.append(["user_id": id, "wrapped": wrapped])
+ }
+
+ guard let tid = await api.createThread(kind: kind, nameBlob: nameBlob, members: members) else {
+ loadError = "Couldn't start the chat. Try again."
+ return nil
+ }
+ await refresh()
+ return tid
+ }
+
+ /// Clear a thread's unread state once it's opened.
+ func markRead(_ thread: Thread) {
+ ReadState.setLastSeen(instance: instance.id, thread: thread.id, at: thread.lastAt)
+ if let i = threads.firstIndex(where: { $0.id == thread.id }) { threads[i].unread = false }
+ }
+
+ private func threadTitle(_ t: ServerThread, key: GroupKey?) -> String {
+ switch t.kind {
+ case "self":
+ return "Notes"
+ case "dm":
+ let other = (t.members ?? []).first { $0 != myUserId } ?? ""
+ return rosterLabels[other] ?? "Someone"
+ default:
+ if let nb = t.name_blob, !nb.isEmpty, let key,
+ let p = try? key.decryptJSON(GroupNamePayload.self, blob: nb),
+ let n = p.name, !n.isEmpty {
+ return n
+ }
+ return "Group"
+ }
+ }
+}
+
+// Per-thread read state (last-seen message time). Just timestamps — no secrets —
+// so UserDefaults is fine (the keys + private material stay in the Keychain).
+enum ReadState {
+ private static func key(_ instance: String, _ thread: String) -> String {
+ "lchat.seen|\(instance)|\(thread)"
+ }
+ static func lastSeen(instance: String, thread: String) -> Int64 {
+ Int64(UserDefaults.standard.integer(forKey: key(instance, thread)))
+ }
+ static func setLastSeen(instance: String, thread: String, at: Int64) {
+ UserDefaults.standard.set(Int(at), forKey: key(instance, thread))
+ }
+}
diff --git a/ios/LChat/Views/Avatar.swift b/ios/LChat/Views/Avatar.swift
new file mode 100644
index 0000000..80c8f04
--- /dev/null
+++ b/ios/LChat/Views/Avatar.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+
+// A simple initials circle. The native sidebar uses initials rather than loading
+// the encrypted profile-photo blobs — the feed webview already renders real avatars
+// inside a thread; the sidebar just needs a stable, recognisable glyph per chat.
+struct Avatar: View {
+ let name: String
+ var size: CGFloat = 40
+
+ var body: some View {
+ Circle()
+ .fill(color)
+ .frame(width: size, height: size)
+ .overlay(
+ Text(initial)
+ .font(.system(size: size * 0.42, weight: .semibold))
+ .foregroundStyle(.white)
+ )
+ }
+
+ private var initial: String {
+ let s = String(name.trimmingCharacters(in: .whitespaces).prefix(1)).uppercased()
+ return s.isEmpty ? "?" : s
+ }
+
+ private var color: Color {
+ let palette: [Color] = [.blue, .purple, .pink, .orange, .green, .teal, .indigo, .red]
+ var h = 5381
+ for b in name.utf8 { h = (h &* 33) &+ Int(b) }
+ return palette[abs(h) % palette.count]
+ }
+}
diff --git a/ios/LChat/Views/ChatListView.swift b/ios/LChat/Views/ChatListView.swift
deleted file mode 100644
index 7c9893c..0000000
--- a/ios/LChat/Views/ChatListView.swift
+++ /dev/null
@@ -1,93 +0,0 @@
-import SwiftUI
-
-// The home screen: every saved chat with its group name and unread count, plus a
-// button to add another. Tapping one opens it.
-struct ChatListView: View {
- @EnvironmentObject var app: AppState
- @State private var showAdd = false
- @State private var groupNames: [String: String] = [:]
- @State private var unread: [String: Int] = [:]
-
- var body: some View {
- NavigationStack {
- Group {
- if app.chats.isEmpty {
- emptyState
- } else {
- list
- }
- }
- .navigationTitle("Chats")
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button { showAdd = true } label: { Image(systemName: "plus") }
- }
- }
- .sheet(isPresented: $showAdd) { AddChatView() }
- .task(id: app.chats) { await refresh() }
- }
- }
-
- private var list: some View {
- List {
- ForEach(app.chats) { chat in
- Button { app.open(chat) } label: { row(chat) }
- .buttonStyle(.plain)
- }
- .onDelete { offsets in
- offsets.map { app.chats[$0] }.forEach(app.remove)
- }
- }
- .refreshable { await refresh() }
- }
-
- private func row(_ chat: SavedChat) -> some View {
- HStack(spacing: 12) {
- VStack(alignment: .leading, spacing: 2) {
- Text(groupNames[chat.id] ?? host(chat)).font(.headline)
- Text("as \(chat.name)").font(.caption).foregroundStyle(.secondary)
- }
- Spacer()
- if let n = unread[chat.id], n > 0 {
- Text("\(n)")
- .font(.caption.bold()).foregroundStyle(.white)
- .padding(.horizontal, 8).padding(.vertical, 3)
- .background(Color.accentColor, in: Capsule())
- }
- Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
- }
- .contentShape(Rectangle())
- .padding(.vertical, 4)
- }
-
- private var emptyState: some View {
- VStack(spacing: 16) {
- Image(systemName: "bubble.left.and.bubble.right")
- .font(.system(size: 48)).foregroundStyle(.secondary)
- Text("No chats yet").font(.title3.weight(.semibold))
- Text("Add a chat with an invite link you were sent.")
- .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
- Button { showAdd = true } label: { Text("Add chat").frame(maxWidth: 220) }
- .buttonStyle(.borderedProminent).controlSize(.large)
- }
- .padding()
- }
-
- private func host(_ chat: SavedChat) -> String {
- chat.session?.baseURL.host ?? "Chat"
- }
-
- // Fetch each chat's group name and unread count. The count is simply the
- // number of messages after lastSeenId — no decryption needed (or possible)
- // here, just the server's opaque count.
- private func refresh() async {
- for chat in app.chats {
- guard let s = chat.session else { continue }
- let api = LChatAPI(baseURL: s.baseURL, token: s.token)
- let name = await api.groupName()
- let count = (try? await api.messages(after: chat.lastSeenId))?.count ?? 0
- groupNames[chat.id] = name
- unread[chat.id] = count
- }
- }
-}
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index d6fdb4f..bb8abbd 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -4,113 +4,71 @@ import PhotosUI
import UniformTypeIdentifiers
import UIKit
-// Webview spike (hybrid): the message *feed* is the web client (chat.js) running
-// in a WKWebView — so styling and how messages render ship for free — while the
-// native shell keeps the title bar and composer for a native feel.
+// One thread's feed. Hybrid: the message *feed* is the web client (chat.js) running
+// in a WKWebView in embed mode — so rendering, reactions, edits, replies, typing and
+// attachments ship for free — while the native shell provides the title and composer.
//
-// The webview loads in "embed" mode (window.__lchatEmbed): chat.js hides its own
-// header + composer and goes full-width, leaving just the feed. The native
-// composer forwards typed text to chat.js's send() via window.lchatNative, so all
-// encryption + WebSocket logic stays in one place (the web layer).
+// E2E is preserved and tightened: native unwraps this thread's AES key (Keychain
+// private key, never leaves the device) and seeds ONLY that key into the page via
+// window.__lchatEmbed. chat.js does all message crypto with it. The webview never
+// sees the user private key or any other thread's key, so a single thread's page
+// can't reach another thread's content.
//
-// E2E is preserved: the Keychain (ChatsStore) stays the source of truth for keys,
-// and ChatWebView seeds this chat's key into the page's localStorage so crypto
-// runs in chat.js — the key is never transmitted; the server sees only ciphertext.
-//
-// Attachments: the native + button (and drag-drop onto the feed) stage a photo/
-// file into the web composer's preview — shown just above the native composer —
-// to be sent with the next message. All upload/encrypt stays in the web layer.
-//
-// Deferred follow-ups: reply/edit banners (replying still works by tapping a
-// bubble in the feed), the web menu's rename/notifications, and unread-badge sync
-// on close.
+// The native composer forwards typed text to chat.js's window.lchatNative.sendText;
+// the + button stages a photo via window.lchatNative.stageAttachment. Both run the
+// one crypto/upload path (the web layer).
struct ChatView: View {
- @EnvironmentObject var app: AppState
@StateObject private var bridge = WebBridge()
- @State private var groupName: String?
@State private var text = ""
@State private var composerFocused = false
- @State private var showLeave = false
@State private var showPhotos = false
@State private var pickerItem: PhotosPickerItem?
@State private var attachError: String?
- let chat: SavedChat
+ let instance: SavedInstance
+ let thread: Thread
+ var myUserId: String = ""
+ let instanceKeyB64: String?
private var isMac: Bool { ProcessInfo.processInfo.isMacCatalystApp }
- // The chat's own name (group name), fetched from the server; falls back to the
- // host until it loads (and before a new-format chat is claimed, when there's no
- // session yet). This is the title — not the URL/host.
- private var title: String { groupName ?? chat.session?.baseURL.host ?? chat.origin?.host ?? "lchat" }
-
private var canSend: Bool {
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || bridge.attachmentStaged
}
var body: some View {
- NavigationStack {
- VStack(spacing: 0) {
- if chat.loadURL != nil {
- // The webview loads `/` (seeded) for a known session, or the
- // `/join/…` link for an unclaimed new-format invite (the web
- // claims + unwraps it, then hands the session back to us).
- ChatWebView(chat: chat, bridge: bridge)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- composer
- } else {
- ContentUnavailableView(
- "Can't open this chat",
- systemImage: "exclamationmark.triangle",
- description: Text("The saved invite link is malformed.")
- )
- }
+ VStack(spacing: 0) {
+ if let url = instance.baseURL {
+ ChatFeedWebView(instance: instance, thread: thread, myUserId: myUserId,
+ instanceKeyB64: instanceKeyB64, origin: url, bridge: bridge)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ composer
+ } else {
+ ContentUnavailableView("Can't open this chat", systemImage: "exclamationmark.triangle",
+ description: Text("This chat's address is invalid."))
}
- .navigationTitle(isMac ? "" : title)
- .navigationBarTitleDisplayMode(.inline)
- .toolbar { toolbarMenu }
- .onAppear { if isMac { composerFocused = true } }
- .background(EdgeSwipeBack { goBack() })
- #if targetEnvironment(macCatalyst)
- .background(MacWindowTitle(title: title))
- #endif
}
- // Re-fetch when a new-format invite becomes claimed (token appears), since
- // the group-name lookup needs the session's token.
- .task(id: chat.claimedToken) { await loadGroupName() }
- .confirmationDialog("Leave this chat?", isPresented: $showLeave, titleVisibility: .visible) {
- Button("Leave", role: .destructive) { leaveChat() }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("This removes it from this device. You'll need the invite link to rejoin.")
- }
- .alert(
- "Couldn’t attach that",
- isPresented: Binding(get: { attachError != nil }, set: { if !$0 { attachError = nil } })
- ) {
+ .navigationTitle(isMac ? "" : thread.title)
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear { if isMac { composerFocused = true } }
+ #if targetEnvironment(macCatalyst)
+ .background(MacWindowTitle(title: thread.title))
+ #endif
+ .alert("Couldn’t attach that",
+ isPresented: Binding(get: { attachError != nil }, set: { if !$0 { attachError = nil } })) {
Button("OK", role: .cancel) {}
} message: {
Text(attachError ?? "")
}
- // The web layer reports upload/stage problems back through the bridge; show
- // them rather than letting an attachment silently vanish.
.onChange(of: bridge.lastError) { _, msg in
if let msg { attachError = msg; bridge.lastError = nil }
}
- // The webview's join flow resolved a new-format invite — persist the
- // session so future opens skip the join screen.
- .onChange(of: bridge.joinedSession) { _, joined in
- if let joined {
- app.resolveSession(token: joined.token, key: joined.key, name: joined.name, for: chat)
- bridge.joinedSession = nil
- }
- }
}
- // Toggling a picker's isPresented binding directly inside a Menu action races
- // with the menu's own dismissal (the picker can fail to appear on Mac Catalyst).
- // Defer the toggle until after the menu has gone.
+ // Toggling a picker's isPresented binding directly inside a Menu action races with
+ // the menu's own dismissal (the picker can fail to appear on Mac Catalyst). Defer
+ // the toggle until after the menu has gone.
private func presentAfterMenu(_ action: @escaping () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: action)
}
@@ -119,12 +77,8 @@ struct ChatView: View {
private var composer: some View {
HStack(alignment: .bottom, spacing: 8) {
- // Attach a photo or file. The bytes are uploaded + encrypted by the web
- // layer (one crypto path) and staged in the preview above the composer,
- // to be sent with the next message — same path as a drag-drop.
Menu {
Button { presentAfterMenu { showPhotos = true } } label: { Label("Photo Library", systemImage: "photo") }
- Button { presentAfterMenu { pickDocument() } } label: { Label("Choose File…", systemImage: "doc") }
} label: {
Image(systemName: "plus")
.font(.system(size: 18, weight: .medium))
@@ -133,9 +87,6 @@ struct ChatView: View {
}
.buttonStyle(.plain)
- // A rounded, grey, growing field reads as a native chat composer. On the
- // Mac a bare Return sends and Shift+Return inserts a newline (handled in
- // ComposerField at the UITextView level).
ComposerField(
text: $text,
focused: $composerFocused,
@@ -155,6 +106,7 @@ struct ChatView: View {
}
.buttonStyle(.plain)
.disabled(!canSend)
+ .accessibilityLabel("Send")
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
@@ -163,35 +115,6 @@ struct ChatView: View {
.onChange(of: pickerItem) { _, item in stagePhoto(item) }
}
- // Present UIKit's document picker directly. SwiftUI's .fileImporter does not
- // reliably fire its completion on Mac Catalyst (the picker opens, you choose a
- // file, and nothing comes back), so we drive UIDocumentPickerViewController and
- // its delegate ourselves — that path is reliable.
- private func pickDocument() {
- presentDocumentPicker { url in
- if let url { stageFile(url) }
- }
- }
-
- @ToolbarContentBuilder
- private var toolbarMenu: some ToolbarContent {
- ToolbarItem(placement: .topBarLeading) {
- Button { goBack() } label: {
- Image(systemName: "chevron.backward")
- }
- .accessibilityLabel("All chats")
- }
- ToolbarItem(placement: .topBarTrailing) {
- Menu {
- Button(role: .destructive) { showLeave = true } label: {
- Label("Leave chat", systemImage: "rectangle.portrait.and.arrow.right")
- }
- } label: {
- Image(systemName: "ellipsis.circle")
- }
- }
- }
-
// MARK: Actions
private func send() {
@@ -199,19 +122,10 @@ struct ChatView: View {
// Only clear on success: if the socket wasn't ready, keep the text so
// nothing is lost and the user can retry.
if sent { text = "" }
- // Keep focus so you can fire off several messages without re-tapping
- // the field; the send button resigns first responder, so re-assert.
composerFocused = true
}
}
- // Hand picked bytes to the web layer to encrypt, upload and stage. The web
- // composer shows an "Uploading…" preview (above this composer) and then the
- // staged chip. Fire-and-forget: success/failure comes back via the bridge.
- private func upload(data: Data, name: String, type: String?) {
- bridge.stageAttachment(data, name: name, type: type)
- }
-
private func stagePhoto(_ item: PhotosPickerItem?) {
guard let item else { return }
Task {
@@ -221,80 +135,44 @@ struct ChatView: View {
attachError = "Couldn’t read that photo."
return
}
- upload(data: data, name: "image.jpg", type: "image/jpeg")
+ bridge.stageAttachment(data, name: "image.jpg", type: "image/jpeg")
} catch {
attachError = "Couldn’t read that photo: \(error.localizedDescription)"
}
}
}
-
- private func stageFile(_ url: URL) {
- let scoped = url.startAccessingSecurityScopedResource()
- defer { if scoped { url.stopAccessingSecurityScopedResource() } }
- do {
- let data = try Data(contentsOf: url)
- let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
- upload(data: data, name: url.lastPathComponent, type: type)
- } catch {
- attachError = "Couldn’t read that file: \(error.localizedDescription)"
- }
- }
-
- private func loadGroupName() async {
- guard let s = chat.session else { return }
- let name = await LChatAPI(baseURL: s.baseURL, token: s.token).groupName()
- groupName = name
- }
-
- private func goBack() {
- app.close(lastSeenId: chat.lastSeenId)
- }
-
- private func leaveChat() {
- app.remove(chat)
- app.current = nil
- }
}
-// Holds a reference to the live WKWebView so the native composer can drive the
-// web client's send() path, and receives staged-attachment state + errors back.
+// Holds a reference to the live WKWebView so the native composer can drive the web
+// client's send() path, and receives staged-attachment state + errors back.
final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler {
weak var webView: WKWebView?
- // Whether the web composer currently has an attachment staged (from the native
- // + button or a drag-drop onto the feed). Drives the native send button so an
- // image-only message can be sent. Kept in sync by the "lchat" message handler.
+ // Whether the web composer currently has a photo staged. Drives the native send
+ // button so an image-only message can be sent. Kept in sync by the "lchat" handler.
@Published var attachmentStaged = false
- // A user-facing problem reported by the web layer (upload/stage failure) or by
- // the bridge call itself. ChatView surfaces it as an alert, then clears it —
- // so an attachment never just silently disappears.
+ // A user-facing problem reported by the web layer (upload/stage failure). ChatView
+ // surfaces it as an alert, then clears it — so an attachment never just vanishes.
@Published var lastError: String?
- // Forwards typed text to chat.js's window.lchatNative.sendText. JSON-encoding
- // the string safely escapes quotes/newlines/unicode before it crosses into JS.
- // Empty text is allowed (the web side sends it only if an attachment is staged).
- // The completion reports whether the web layer accepted it (socket open, hook
- // present, something to send), so the caller knows whether to clear its field.
+ // Forwards typed text to chat.js's window.lchatNative.sendText. JSON-encoding the
+ // string safely escapes quotes/newlines/unicode before it crosses into JS. The
+ // completion reports whether the web layer accepted it (socket open, hook present,
+ // something to send), so the caller knows whether to clear its field.
func sendText(_ text: String, completion: @escaping (Bool) -> Void) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let webView,
let data = try? JSONSerialization.data(withJSONObject: [trimmed]),
let arg = String(data: data, encoding: .utf8) else { return completion(false) }
- // arg is `["the text"]`; pass element 0 to the hook. The `?:` yields false
- // if the hook isn't installed yet, so an early send is reported as not sent.
webView.evaluateJavaScript("window.lchatNative ? window.lchatNative.sendText(\(arg)[0]) : false") { result, _ in
completion((result as? Bool) ?? false)
}
}
- // Hands attachment bytes to chat.js's window.lchatNative.stageAttachment, which
- // uploads + encrypts + previews it (same path as a drag-drop). Uses
- // evaluateJavaScript — the same call the text send uses, which reliably reaches
- // window.lchatNative here; callAsyncJavaScript silently did nothing on Catalyst.
- // Fire-and-forget: staging runs async in JS and reports back via the "lchat"
- // message handler ({staged:true} on success, {error} on failure). We only flag
- // the kickoff problems (no hook yet, or a JS error) synchronously here.
+ // Hands photo bytes to chat.js's window.lchatNative.stageAttachment, which uploads
+ // + encrypts + previews it. Fire-and-forget: staging runs async in JS and reports
+ // back via the "lchat" handler ({staged:true} on success, {error} on failure).
func stageAttachment(_ data: Data, name: String, type: String?) {
guard let webView else {
lastError = "Internal error: the chat view isn’t ready."
@@ -310,9 +188,6 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler {
lastError = "Couldn’t prepare that attachment."
return
}
- // arg is a JSON object literal (valid JS). Return a sentinel so a missing
- // hook is distinguishable from a successful kickoff (the upload itself
- // reports completion/failure asynchronously via the message handler).
let js = "(function(){ if (!window.lchatNative) return 'no-bridge'; window.lchatNative.stageAttachment(\(arg)); return 'ok'; })()"
webView.evaluateJavaScript(js) { [weak self] result, err in
if let err = err {
@@ -323,47 +198,25 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler {
}
}
- // A session the web join flow resolved for a new-format invite, for the view to
- // persist. Equatable so .onChange fires.
- struct JoinedSession: Equatable {
- let token: String
- let key: String
- let name: String
- }
- @Published var joinedSession: JoinedSession?
-
- // chat.js posts {staged: Bool} whenever an attachment is staged or cleared
- // (picker, drag-drop, send, or the preview's remove button) and {error: String}
- // when an upload/stage fails. join.js posts {joined: {token,key,name}} after it
- // claims + unwraps a new-format invite. All arrive on the one "lchat" handler.
+ // chat.js posts {staged: Bool} whenever a photo is staged or cleared, and
+ // {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 let staged = body["staged"] as? Bool { attachmentStaged = staged }
if let err = body["error"] as? String { lastError = err }
- if let j = body["joined"] as? [String: Any],
- let token = j["token"] as? String, let key = j["key"] as? String, let name = j["name"] as? String {
- joinedSession = JoinedSession(token: token, key: key, name: name)
- }
}
}
-// WKWebView host for one chat's message feed.
-//
-// When we already have the session (legacy `#k=` link, or a new-format invite we
-// claimed earlier), we load the origin root (`/`) and seed the session into
-// localStorage before any page script runs, so chat.js renders the feed directly
-// (no join prompt).
-//
-// For an unclaimed new-format invite we have no key, so we load the saved
-// `/join/<slug>#<T>` link instead: join.js claims it, unwraps the server-stored
-// key, stores the session in localStorage, hands it back to us (the "lchat"
-// message handler) and redirects to `/`. Either way the embed flag is set.
-//
-// The default (persistent) data store means this storage and chat.js's cached
-// state survive between opens. Each chat is its own origin (per-instance
-// subdomain), so the browser partitions their storage automatically.
-struct ChatWebView: UIViewRepresentable {
- let chat: SavedChat
+// WKWebView host for one thread's feed. Loads the instance origin (`/`) and injects
+// window.__lchatEmbed at documentStart with the material chat.js needs to render this
+// one thread: the device token, our screen name + user id, the thread id, and the
+// already-unwrapped thread key (plus the instance key for avatars, if we have it).
+struct ChatFeedWebView: UIViewRepresentable {
+ let instance: SavedInstance
+ let thread: Thread
+ let myUserId: String
+ let instanceKeyB64: String?
+ let origin: URL
let bridge: WebBridge
func makeUIView(context: Context) -> WKWebView {
@@ -373,92 +226,34 @@ struct ChatWebView: UIViewRepresentable {
if let script = bootScript() {
cfg.userContentController.addUserScript(script)
}
- // Receive staged-attachment state + the resolved join session from the web
- // (window.webkit.messageHandlers.lchat).
cfg.userContentController.add(bridge, name: "lchat")
let web = WKWebView(frame: .zero, configuration: cfg)
bridge.webView = web
- // In debug builds, let Safari's Web Inspector attach to the feed
- // (Develop ▸ device ▸ this view) so the web console is reachable.
#if DEBUG
if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true }
#endif
- // session present → origin `/` (seeded); else the `/join/…` link.
- if let url = chat.loadURL { web.load(URLRequest(url: url)) }
+ web.load(URLRequest(url: origin))
return web
}
func updateUIView(_ web: WKWebView, context: Context) {}
- // Runs before any page script: marks this as the native embed (so chat.js/join.js
- // hide the web header/composer, expose the send hook, and use the native join
- // path) and — when we have a session — seeds it into localStorage so chat.js
- // renders directly. For an unclaimed new-format invite there's no session to
- // seed; join.js establishes it.
+ // Runs before any page script: hand chat.js the embed seed for this thread. Only
+ // the thread key crosses over — never the user private key.
private func bootScript() -> WKUserScript? {
- var js = "window.__lchatEmbed = true;"
- if let session = chat.session {
- let payload = ["token": session.token, "key": session.keyB64, "name": session.name]
- if let data = try? JSONSerialization.data(withJSONObject: payload),
- let objJSON = String(data: data, encoding: .utf8) {
- // objJSON is a JSON object literal (valid JS); JSON.stringify turns it
- // back into the string chat.js JSON.parses, sidestepping manual
- // escaping of a screen name that might contain quotes.
- js += "\ntry { localStorage.setItem('lchat_session', JSON.stringify(\(objJSON))); } catch (e) {}"
- }
- }
+ var seed: [String: Any] = [
+ "token": instance.deviceToken,
+ "name": instance.screenName,
+ "myUserId": myUserId,
+ "threadId": thread.id,
+ "threadKey": thread.threadKeyB64,
+ ]
+ 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 }
+ // json is a JSON object literal (valid JS). Assigning it as the flag means
+ // chat.js both detects embed mode and reads the seed from one global.
+ let js = "window.__lchatEmbed = \(json);"
return WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true)
}
}
-
-// Presents UIKit's document picker directly (SwiftUI's .fileImporter doesn't fire
-// its completion on Mac Catalyst). asCopy:true hands back a readable temp copy, so
-// the caller can read bytes without the security-scoped-resource dance.
-@MainActor
-private func presentDocumentPicker(onPick: @escaping (URL?) -> Void) {
- let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true)
- picker.allowsMultipleSelection = false
- let delegate = DocumentPickerDelegate(onPick: onPick)
- picker.delegate = delegate
- guard let presenter = topViewController() else {
- onPick(nil)
- return
- }
- presenter.present(picker, animated: true)
-}
-
-// Walks to the front-most view controller of the active foreground scene to present
-// from. Catalyst exposes a single UIWindowScene.
-@MainActor
-private func topViewController() -> UIViewController? {
- let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
- let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
- guard var top = scene?.keyWindow?.rootViewController
- ?? scene?.windows.first(where: { $0.isKeyWindow })?.rootViewController
- ?? scene?.windows.first?.rootViewController else { return nil }
- while let presented = top.presentedViewController { top = presented }
- return top
-}
-
-// UIDocumentPickerDelegate whose callbacks reliably fire on Catalyst. It retains
-// itself until a callback lands (the picker only holds the delegate weakly).
-private final class DocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
- private let onPick: (URL?) -> Void
- private var selfRef: DocumentPickerDelegate?
-
- init(onPick: @escaping (URL?) -> Void) {
- self.onPick = onPick
- super.init()
- selfRef = self
- }
-
- func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
- onPick(urls.first)
- selfRef = nil
- }
-
- func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
- onPick(nil)
- selfRef = nil
- }
-}
diff --git a/ios/LChat/Views/InstanceSidebar.swift b/ios/LChat/Views/InstanceSidebar.swift
new file mode 100644
index 0000000..235c462
--- /dev/null
+++ b/ios/LChat/Views/InstanceSidebar.swift
@@ -0,0 +1,76 @@
+import SwiftUI
+
+// The server switcher: the Mac app's rail, as a slide-in drawer. Every saved
+// server, the active one ticked, plus the same "Add a chat" flow as the Mac.
+// Presented by RootView as a leading overlay; tapping the dimmed area dismisses.
+struct ServerDrawer: View {
+ @EnvironmentObject var app: AppState
+ let activeID: String?
+ var onSelect: (SavedInstance) -> Void
+ var onAdd: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Text("Servers")
+ .font(.title2.weight(.semibold))
+ .padding(.horizontal, 20)
+ .padding(.top, 18)
+ .padding(.bottom, 10)
+
+ ScrollView {
+ VStack(spacing: 2) {
+ ForEach(app.instances) { inst in
+ row(inst)
+ }
+ }
+ .padding(.horizontal, 10)
+ }
+
+ 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)
+ }
+ .frame(width: 300)
+ .frame(maxHeight: .infinity)
+ // The panel paints to the screen edge, but the content (the Add row) stays
+ // inside the safe area so it clears the home indicator.
+ .background(Color(.systemBackground).ignoresSafeArea())
+ }
+
+ private func row(_ inst: SavedInstance) -> some View {
+ Button { onSelect(inst) } label: {
+ HStack(spacing: 12) {
+ Avatar(name: inst.displayName, size: 36)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(inst.displayName).font(.headline).lineLimit(1)
+ Text("as \(inst.screenName)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
+ }
+ Spacer()
+ if inst.id == activeID {
+ Image(systemName: "checkmark")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(Color.accentColor)
+ }
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .background(inst.id == activeID ? Color(.secondarySystemFill) : .clear,
+ in: RoundedRectangle(cornerRadius: 10))
+ .contextMenu {
+ Button(role: .destructive) { app.remove(inst) } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ }
+ }
+}
diff --git a/ios/LChat/Views/JoinView.swift b/ios/LChat/Views/JoinView.swift
index 08d77c1..fada765 100644
--- a/ios/LChat/Views/JoinView.swift
+++ b/ios/LChat/Views/JoinView.swift
@@ -1,130 +1,203 @@
import SwiftUI
+import WebKit
-// Add a chat: paste an invite link + pick a screen name. The link carries the
-// secret key in its #fragment; we validate it on-device and never send it
-// anywhere. Presented as a sheet from the chat list.
-struct AddChatView: View {
+// 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 name = ""
+ @State private var joinURL: URL?
+ @State private var origin = ""
@State private var error: String?
- @State private var busy = false
+ @State private var pending: Task<Void, Never>?
+
+ // 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 {
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- Text("Paste the invite link you were sent. It contains a secret key that stays on this device.")
- .font(.callout)
- .foregroundStyle(.secondary)
-
- VStack(alignment: .leading, spacing: 6) {
- Text("Invite link").font(.subheadline.weight(.semibold))
- TextField("https://…/join/…", text: $link, axis: .vertical)
- .textFieldStyle(.roundedBorder)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .keyboardType(.URL)
- .onChange(of: link) { _, _ in prefillName() }
- }
-
- VStack(alignment: .leading, spacing: 6) {
- Text("Your screen name").font(.subheadline.weight(.semibold))
- TextField("e.g. Sam", text: $name)
- .textFieldStyle(.roundedBorder)
- .autocorrectionDisabled()
- }
-
- if let error {
- Text(error).font(.callout).foregroundStyle(.red)
- }
-
- Button(action: add) {
- if busy { ProgressView() } else { Text("Add chat").frame(maxWidth: .infinity) }
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .disabled(busy)
+ Group {
+ if let joinURL {
+ JoinWebView(url: joinURL, onJoined: complete, onError: { msg in
+ error = msg
+ self.joinURL = nil
+ })
+ } else {
+ pasteForm
}
- .padding()
}
- .navigationTitle("Add chat")
+ .navigationTitle("Add a chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
- ToolbarItem(placement: .topBarLeading) {
- Button("Cancel") { dismiss() }
- }
+ ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
}
}
}
- // Fill the name field from the invite's label, once, if it's still empty.
- private func prefillName() {
- guard name.isEmpty, let p = LChatAPI.parseInvite(link) else { return }
- Task {
- if let info = try? await LChatAPI.inviteInfo(baseURL: p.baseURL, token: p.token),
- let label = info.label, !label.isEmpty, name.isEmpty {
- name = label
- }
- }
- }
+ 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.")
+ }
- private func add() {
- error = nil
- let trimmedLink = link.trimmingCharacters(in: .whitespacesAndNewlines)
- let screenName = String(name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(32))
- guard !screenName.isEmpty else {
- error = "Please enter a screen name."
- return
- }
+ 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 { start() }
+ }
+ }
- // Legacy `#k=` link: the key rides in the URL, so validate and claim natively.
- if let p = LChatAPI.parseInvite(trimmedLink) {
- guard (try? GroupKey(base64url: p.keyB64)) != nil else {
- error = "This link's secret key is malformed."
- return
- }
- busy = true
- Task {
- defer { busy = false }
- guard let info = try? await LChatAPI.inviteInfo(baseURL: p.baseURL, token: p.token) else {
- error = "Couldn't reach the server. Try again."
- return
- }
- guard info.ok else {
- error = (info.revoked == true) ? "This invite was turned off." : "This invite link is invalid."
- return
+ 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)
}
- await LChatAPI.claim(baseURL: p.baseURL, token: p.token)
- app.add(url: trimmedLink, name: screenName)
- dismiss()
}
- return
+ .padding()
}
+ }
- // New-format (server-blind) link: the key isn't in the URL — it's fetched
- // and unwrapped by the web join flow inside the WKWebView. We can't validate
- // or claim it natively, so save the link (carrying the name so the web join
- // is promptless) and open it; the webview does the rest.
- guard let joinURL = newInviteURL(trimmedLink, name: screenName) else {
- error = "That doesn't look like a valid invite link."
- return
+ 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)
}
- app.add(url: joinURL, name: screenName)
+ }
+
+ 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()
}
+}
- // Validate a new-format `/join/<slug>#<T>` link and append `~<name>` so join.js
- // signs in without re-prompting. Returns nil if it isn't a usable join link.
- private func newInviteURL(_ link: String, name: String) -> String? {
- guard let c = URLComponents(string: link),
- let scheme = c.scheme, scheme == "http" || scheme == "https",
- c.host != nil, c.path.contains("/join/"),
- let frag = c.fragment, !frag.isEmpty, !frag.contains("k=")
- else { return nil }
- if frag.contains("~") { return link } // already carries a name
- let encoded = name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? name
- return "\(link)~\(encoded)"
+// The fields the web join flow hands back.
+struct JoinedIdentity {
+ let token: String
+ let name: String
+ let userPub: String
+ let userPriv: String
+}
+
+// Validate a new-format `/join/<slug>#<T>` link and reduce it to (origin, joinURL).
+// Legacy `#k=` links are rejected — this client targets only the keypair model.
+func parseJoinLink(_ raw: String) -> (origin: String, joinURL: URL)? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let c = URLComponents(string: trimmed),
+ let scheme = c.scheme, let host = c.host,
+ scheme == "http" || scheme == "https",
+ c.path.contains("/join/"),
+ let frag = c.fragment, !frag.isEmpty, !frag.contains("k="),
+ let joinURL = URL(string: trimmed)
+ else { return nil }
+ var o = URLComponents()
+ o.scheme = scheme
+ o.host = host
+ o.port = c.port
+ guard let origin = o.url else { return nil }
+ return (origin.absoluteString, joinURL)
+}
+
+// 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))
+ }
}
}
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift
new file mode 100644
index 0000000..fec8d29
--- /dev/null
+++ b/ios/LChat/Views/NewChatView.swift
@@ -0,0 +1,172 @@
+import SwiftUI
+
+// Start a new chat, iMessage-style: the sheet lists people — tap one and the DM
+// starts immediately (the server dedupes, so an existing DM just opens). "New
+// group" pushes the multi-select flow. Agent helpers get their own section with
+// a BOT badge, mirroring the web picker. All key wrapping happens natively in
+// ThreadStore.createChat.
+struct NewChatView: View {
+ @ObservedObject var store: ThreadStore
+ @Environment(\.dismiss) private var dismiss
+ let onCreated: (String) -> Void
+
+ @State private var busyWith: String? // user id of the DM being created
+ @State private var error: String?
+
+ private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } }
+ private var people: [RosterEntry] { others.filter { !$0.isAgent } }
+ private var helpers: [RosterEntry] { others.filter { $0.isAgent } }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ NavigationLink {
+ NewGroupView(store: store, onCreated: finish)
+ } label: {
+ Label {
+ Text("New group").font(.body.weight(.medium))
+ } icon: {
+ Image(systemName: "person.3.fill")
+ .foregroundStyle(Color.accentColor)
+ }
+ }
+ }
+
+ if others.isEmpty {
+ Section {
+ Text("No one else has joined this chat yet.")
+ .foregroundStyle(.secondary)
+ }
+ }
+ if !people.isEmpty {
+ Section("People") {
+ ForEach(people, id: \.user_id) { person in personRow(person) }
+ }
+ }
+ if !helpers.isEmpty {
+ Section("Helpers") {
+ ForEach(helpers, id: \.user_id) { person in personRow(person) }
+ }
+ }
+ if let error {
+ Section { Text(error).foregroundStyle(.red) }
+ }
+ }
+ .navigationTitle("New chat")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
+ }
+ }
+ }
+
+ private func personRow(_ person: RosterEntry) -> some View {
+ Button { startDM(person) } label: {
+ HStack(spacing: 12) {
+ Avatar(name: person.label, size: 36)
+ Text(person.label).foregroundStyle(.primary)
+ if person.isAgent { BotBadge() }
+ Spacer()
+ if busyWith == person.user_id { ProgressView() }
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(busyWith != nil)
+ }
+
+ private func startDM(_ person: RosterEntry) {
+ busyWith = person.user_id
+ error = nil
+ Task {
+ let id = await store.createChat(memberIDs: [person.user_id], groupName: nil)
+ busyWith = nil
+ if let id { finish(id) } else { error = store.loadError ?? "Couldn't start the chat." }
+ }
+ }
+
+ private func finish(_ id: String) {
+ onCreated(id)
+ dismiss()
+ }
+}
+
+// The multi-select group flow, pushed from NewChatView.
+struct NewGroupView: View {
+ @ObservedObject var store: ThreadStore
+ let onCreated: (String) -> Void
+
+ @State private var selected: Set<String> = []
+ @State private var groupName = ""
+ @State private var busy = false
+ @State private var error: String?
+
+ private var others: [RosterEntry] { store.roster.filter { $0.user_id != store.myUserId } }
+
+ var body: some View {
+ Form {
+ Section("Group name") {
+ TextField("Optional", text: $groupName)
+ .autocorrectionDisabled()
+ }
+ Section(header: Text(selected.isEmpty ? "Add people" : "\(selected.count) selected")) {
+ ForEach(others, id: \.user_id) { person in
+ Button { toggle(person.user_id) } label: {
+ HStack(spacing: 12) {
+ Avatar(name: person.label, size: 32)
+ Text(person.label).foregroundStyle(.primary)
+ if person.isAgent { BotBadge() }
+ Spacer()
+ if selected.contains(person.user_id) {
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(.tint)
+ } else {
+ Image(systemName: "circle").foregroundStyle(.tertiary)
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ if let error {
+ Section { Text(error).foregroundStyle(.red) }
+ }
+ }
+ .navigationTitle("New group")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ if busy { ProgressView() }
+ else {
+ Button("Create") { create() }
+ .disabled(selected.isEmpty)
+ }
+ }
+ }
+ }
+
+ private func toggle(_ id: String) {
+ if selected.contains(id) { selected.remove(id) } else { selected.insert(id) }
+ }
+
+ private func create() {
+ busy = true
+ error = nil
+ Task {
+ let id = await store.createChat(memberIDs: Array(selected), groupName: groupName)
+ busy = false
+ if let id { onCreated(id) } else { error = store.loadError ?? "Couldn't create the group." }
+ }
+ }
+}
+
+// The "BOT" tag next to agent helpers — mirrors the web picker's badge.
+struct BotBadge: View {
+ var body: some View {
+ Text("BOT")
+ .font(.caption2.weight(.bold))
+ .padding(.horizontal, 5)
+ .padding(.vertical, 2)
+ .background(Color.accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 4))
+ .foregroundStyle(Color.accentColor)
+ }
+}
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
new file mode 100644
index 0000000..ddf0d1d
--- /dev/null
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -0,0 +1,113 @@
+import SwiftUI
+
+// The main index: the active server's chats (DMs, groups, Notes), each with a
+// decrypted name + last-message preview. Tapping one pushes its feed; the top-left
+// server button slides in the server drawer (see RootView). The ThreadStore does
+// the native ECIES unwrap + preview decryption; this view is just presentation.
+struct ThreadListView: View {
+ @EnvironmentObject var app: AppState
+ @ObservedObject var store: ThreadStore
+ @Binding var path: [String]
+ @Binding var showServers: Bool
+ @State private var showNew = false
+
+ var body: some View {
+ List {
+ ForEach(store.threads) { t in
+ NavigationLink(value: t.id) { row(t) }
+ }
+ }
+ .listStyle(.plain)
+ .overlay { if store.loading && store.threads.isEmpty { ProgressView() } }
+ .overlay { if !store.loading && store.threads.isEmpty { empty } }
+ .navigationTitle(store.instanceName.isEmpty ? store.instance.displayName : store.instanceName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button { withAnimation(.easeOut(duration: 0.22)) { showServers = true } } label: {
+ Avatar(name: store.instanceName.isEmpty ? store.instance.displayName : store.instanceName,
+ size: 30)
+ }
+ .accessibilityLabel("Servers")
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button { showNew = true } label: { Image(systemName: "square.and.pencil") }
+ .accessibilityLabel("New chat")
+ }
+ }
+ .sheet(isPresented: $showNew) {
+ NewChatView(store: store) { newID in path.append(newID) }
+ }
+ .navigationDestination(for: String.self) { id in
+ if let t = store.threads.first(where: { $0.id == id }) {
+ ChatView(instance: store.instance, thread: t,
+ myUserId: store.myUserId, instanceKeyB64: store.instanceKey())
+ .id(store.instance.id + "|" + t.id) // fresh webview per thread
+ .onAppear { store.markRead(t) }
+ } else {
+ ContentUnavailableView("Chat not found", systemImage: "bubble.left")
+ }
+ }
+ .refreshable { await load() }
+ // Keyed on the instance: when the store swaps in place (switching server),
+ // SwiftUI reuses this view, so an un-keyed .task would not re-fire and the new
+ // server's chats would never load.
+ .task(id: store.instance.id) { await load() }
+ }
+
+ private func row(_ t: Thread) -> some View {
+ HStack(spacing: 12) {
+ Avatar(name: t.title, size: 44)
+ VStack(alignment: .leading, spacing: 2) {
+ HStack {
+ Text(t.title).font(.headline).lineLimit(1)
+ Spacer()
+ if t.lastAt > 0 {
+ Text(relTime(t.lastAt)).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ HStack(spacing: 6) {
+ Text(t.preview.isEmpty ? " " : t.preview)
+ .font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
+ Spacer()
+ if t.unread {
+ Circle().fill(Color.accentColor).frame(width: 9, height: 9)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private var empty: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "tray").font(.system(size: 36)).foregroundStyle(.secondary)
+ Text("No chats yet").font(.headline)
+ Text("Tap the compose button to start one.")
+ .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
+ Button { showNew = true } label: { Text("New chat").frame(maxWidth: 200) }
+ .buttonStyle(.borderedProminent)
+ }
+ .padding()
+ }
+
+ private func load() async {
+ await store.refresh()
+ app.note(id: store.instance.id, myUserId: store.myUserId, groupName: store.instanceName)
+ }
+}
+
+// A compact "last activity" label — mirrors relTime in web/static/chat.js.
+private func relTime(_ sec: Int64) -> String {
+ guard sec > 0 else { return "" }
+ let d = Date(timeIntervalSince1970: TimeInterval(sec))
+ let cal = Calendar.current
+ if cal.isDateInToday(d) {
+ return d.formatted(date: .omitted, time: .shortened)
+ }
+ if cal.isDateInYesterday(d) { return "Yesterday" }
+ if let days = cal.dateComponents([.day], from: d, to: Date()).day, days < 7 {
+ return d.formatted(.dateTime.weekday(.abbreviated))
+ }
+ return d.formatted(.dateTime.day().month(.twoDigits))
+}
diff --git a/ios/LChatTests/ECIESVectorTest.swift b/ios/LChatTests/ECIESVectorTest.swift
new file mode 100644
index 0000000..4dab919
--- /dev/null
+++ b/ios/LChatTests/ECIESVectorTest.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+// Standalone gate for the native ECIES unwrap. Compiles the real Crypto.swift and
+// asserts it unwraps the committed cross-implementation fixture (produced by the web
+// crypto in test/threadkey.test.mjs) to the expected thread key. Run via:
+//
+// cd ios && swiftc -parse-as-library LChat/Crypto.swift \
+// LChatTests/ECIESVectorTest.swift -o /tmp/eciesvec \
+// && /tmp/eciesvec LChatTests/thread-key-vector.json
+//
+// A non-zero exit means the native ECIES has drifted from the web's wire format.
+@main
+struct ECIESVectorTest {
+ struct Vector: Decodable {
+ let privPkcs8B64: String
+ let pubRawB64: String
+ let wrappedB64: String
+ let expectedThreadKeyB64: String
+ }
+
+ static func main() {
+ let args = CommandLine.arguments
+ let path = args.count > 1 ? args[1] : "LChatTests/thread-key-vector.json"
+ guard let data = FileManager.default.contents(atPath: path),
+ let v = try? JSONDecoder().decode(Vector.self, from: data) else {
+ fail("could not read/parse fixture at \(path)")
+ }
+ guard let expected = Data(base64urlEncoded: v.expectedThreadKeyB64) else {
+ fail("fixture expectedThreadKeyB64 is not valid base64url")
+ }
+ do {
+ let got = try ECIES.unwrap(wrappedB64: v.wrappedB64, privPkcs8B64: v.privPkcs8B64)
+ guard got == expected else {
+ fail("unwrapped key != expected\n got: \(got.base64urlEncodedString())\n expected: \(v.expectedThreadKeyB64)")
+ }
+ // Wrap round-trip: wrap a known key to the fixture's public key, then
+ // unwrap with its private key. Validates ECIES.wrap (used when starting a
+ // chat) produces a blob the unwrap path — and thus the web — can open.
+ let fresh = try ECIES.wrap(rawKey: expected, recipientPubB64: v.pubRawB64)
+ let back = try ECIES.unwrap(wrappedB64: fresh, privPkcs8B64: v.privPkcs8B64)
+ guard back == expected else { fail("wrap→unwrap round-trip mismatch") }
+ print("ECIESVectorTest OK")
+ } catch {
+ fail("ECIES.unwrap threw: \(error)")
+ }
+ }
+
+ static func fail(_ msg: String) -> Never {
+ FileHandle.standardError.write(Data("FAIL: \(msg)\n".utf8))
+ exit(1)
+ }
+}
diff --git a/ios/LChatTests/thread-key-vector.json b/ios/LChatTests/thread-key-vector.json
new file mode 100644
index 0000000..2b8176e
--- /dev/null
+++ b/ios/LChatTests/thread-key-vector.json
@@ -0,0 +1,7 @@
+{
+ "_comment": "ECIES thread-key test vector. Generated by test/threadkey.test.mjs from web/static/crypto.js. The Swift LChatTests unwraps wrappedB64 with privPkcs8B64 and must get expectedThreadKeyB64 — a cross-implementation gate so the native ECIES never drifts from the web's. base64url throughout. Regenerate with: node test/threadkey.test.mjs --emit",
+ "privPkcs8B64": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgHERydCsyayphKWBF_ZvnsVAdo4rkmj46SVCd7nvyScehRANCAAQEwK724nmVbpFc-HXEZTt76k0kDgjG75vQ5vDilG6gGogNkYj8-my6TRgtN380yWGO93G87VSi4qMCqqztkGWn",
+ "pubRawB64": "BATArvbieZVukVz4dcRlO3vqTSQOCMbvm9Dm8OKUbqAaiA2RiPz6bLpNGC03fzTJYY73cbztVKLiowKqrO2QZac",
+ "wrappedB64": "BMSCxNIL5x0UaozrSy4uC9faSE_2st77jmlGRQssrFiqL_b1wuYuaUjrZZ14FCJnXCk0Y8FDYaqWs1HwPdVC5oo0xn9sNphLehiNdYDRKwiPiBJQUL4exXqAuvWbxa2NkteBVvBhRv0DaMxu02BmYRbArErXam2T3l-v61c",
+ "expectedThreadKeyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
+}
diff --git a/ios/LChatUITests/JoinAndChatTests.swift b/ios/LChatUITests/JoinAndChatTests.swift
new file mode 100644
index 0000000..dcf744d
--- /dev/null
+++ b/ios/LChatUITests/JoinAndChatTests.swift
@@ -0,0 +1,84 @@
+import XCTest
+
+// End-to-end drive of the real app against a live server: join via a member
+// invite link, land in the DM, send a message, and see the other member's live
+// reply arrive over the socket. The server URL/invite ride in via env
+// (TEST_RUNNER_INVITE_LINK etc. on the xcodebuild invocation); a scripted peer
+// ("Paula") must be listening server-side to produce the reply.
+final class JoinAndChatTests: XCTestCase {
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ }
+
+ func testJoinThreadListSendReceive() throws {
+ let env = ProcessInfo.processInfo.environment
+ guard let invite = env["INVITE_LINK"], !invite.isEmpty else {
+ throw XCTSkip("Set TEST_RUNNER_INVITE_LINK to a /join/… link for a live server.")
+ }
+ let outgoing = env["SEND_TEXT"] ?? "hello from Eleven on iOS"
+ let expectedReply = env["EXPECT_REPLY"] ?? "hello back from Paula"
+
+ let app = XCUIApplication()
+ app.launch()
+
+ // Fresh install → empty state → "Add a chat"
+ let add = app.buttons["Add a chat"].firstMatch
+ XCTAssertTrue(add.waitForExistence(timeout: 10), "no Add a chat button")
+ shoot(app, "01-empty-state")
+ add.tap()
+
+ // Paste the invite link — a valid link starts the join on its own (no
+ // button), matching the Mac app.
+ let field = app.textFields.firstMatch
+ XCTAssertTrue(field.waitForExistence(timeout: 5), "no invite link field")
+ field.tap()
+ field.typeText(invite)
+
+ // The web join form: screen name is prefilled from the invite label.
+ let join = webElement(app, "Join the chat")
+ XCTAssertTrue(join.waitForExistence(timeout: 20), "web join form never appeared")
+ shoot(app, "02-join-form")
+ join.tap()
+
+ // Back in native: the thread list shows the DM with the inviter.
+ let paula = app.staticTexts["Paula"].firstMatch
+ XCTAssertTrue(paula.waitForExistence(timeout: 25), "DM with Paula not in thread list")
+ shoot(app, "03-thread-list")
+ paula.tap()
+
+ // The feed opens; type into the native composer and send.
+ let composer = app.textViews.firstMatch
+ XCTAssertTrue(composer.waitForExistence(timeout: 15), "no composer")
+ composer.tap()
+ composer.typeText(outgoing)
+ let send = app.buttons["Send"].firstMatch
+ XCTAssertTrue(send.waitForExistence(timeout: 5), "no send button")
+ send.tap()
+
+ // Our message renders in the webview feed…
+ let mine = webElement(app, outgoing)
+ XCTAssertTrue(mine.waitForExistence(timeout: 20), "sent message never rendered")
+ shoot(app, "04-message-sent")
+
+ // …and Paula's scripted reply arrives live over the socket.
+ let reply = webElement(app, expectedReply)
+ XCTAssertTrue(reply.waitForExistence(timeout: 60), "Paula's reply never rendered")
+ shoot(app, "05-reply-received")
+ }
+
+ // Web content exposes elements inconsistently (button vs link vs static text
+ // depending on markup) — match any descendant by label.
+ private func webElement(_ app: XCUIApplication, _ label: String) -> XCUIElement {
+ app.webViews.descendants(matching: .any).matching(
+ NSPredicate(format: "label CONTAINS %@", label)
+ ).firstMatch
+ }
+
+ private func shoot(_ app: XCUIApplication, _ name: String) {
+ let shot = XCTAttachment(screenshot: app.screenshot())
+ shot.name = name
+ shot.lifetime = .keepAlways
+ add(shot)
+ }
+}
diff --git a/ios/README.md b/ios/README.md
index 05cd918..c438eff 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -2,24 +2,43 @@
A native SwiftUI client for [lchat](../README.md), running on **iPhone/iPad** and
on **macOS via Mac Catalyst** (same target, same code). Same end-to-end
-encryption contract as the web app: the AES-GCM group key lives only in the
-invite link's `#fragment`, stays on-device (Keychain), and the server only ever
-sees opaque ciphertext and blob ids.
+encryption contract as the web app, in the multi-thread model: each instance is a
+**workspace of threads** (DMs, groups, Notes); identity is a per-instance **ECDH
+P-256 keypair**; and every thread has its own AES key **wrapped to each member's
+public key** (ECIES). The user **private key never leaves the device** (Keychain),
+and the server only ever sees opaque ciphertext and wrapped key blobs it can't open.
+
+## Shape: native sidebar, webview feed
+
+The app is a hybrid. The **shell is native** — a workspace switcher → a thread
+sidebar (decrypted names + last-message previews) → a feed with a native composer.
+The **message feed is the web client** (`chat.js`) running in a `WKWebView` in embed
+mode, so rendering, reactions, edits, replies, typing and attachments ship from one
+codebase.
+
+Crucially, native does the **identity crypto** (`Crypto.swift`): it unwraps each
+thread's key with the Keychain private key (ECIES) and decrypts just the sidebar's
+names/previews. It then hands the webview **only the active thread's key** — never
+the private key, never another thread's key — so a single thread's page can't reach
+any other thread's content. Message-content crypto stays single-sourced in `chat.js`,
+reached through `window.__lchatEmbed` (seed in) and `window.lchatNative` (send/attach).
## What it does
-- **Join** by pasting an invite link (`https://host/join/<token>#k=…`). The host
- is taken from the link — nothing is hardcoded.
-- **Read** history + live messages (text, inline images, reply quotes) over the
- `/ws` WebSocket, with auto-reconnect and gap-fill.
-- **Send** text and **photos** (downscaled + re-encoded on-device, which also
- strips EXIF/GPS — there is no server-side image proxy).
-- **React** (long-press a message → pick from the same six-emoji set the server
- allows) and **reply** (quoted, iMessage-style; the thread link rides inside the
- encrypted payload).
-
-Out of scope for this first cut: push notifications (needs APNs + a paid Apple
-developer account + server work) and link-preview cards.
+- **Add a workspace** by pasting a new-format invite link
+ (`https://host/join/<slug>#<T>`). The keypair setup + claim runs in a one-shot
+ join webview (`join.js`), which hands the resolved identity back to native; the
+ private key is stored in the Keychain. The host is taken from the link.
+- **Browse threads** natively: DMs, groups and Notes, each with a decrypted title
+ and last-message preview, ordered by recency.
+- **Read + send** in a thread: history and live messages over `/ws`, text and
+ **photos** (downscaled + re-encoded on-device, stripping EXIF/GPS). Reactions,
+ edits, replies and typing render in the webview feed.
+
+Out of scope for this cut (deferred follow-ups): creating threads / managing members
+from native (do it in the web app for now), native reply/edit banners above the
+composer, profile/group avatar editing, and push notifications (needs APNs + server
+work; unread refreshes on foreground for now).
## Build & run
@@ -99,19 +118,34 @@ can fire off several messages without clicking back in.
`NSAllowsLocalNetworking` is enabled so you can point the app at a `go run .`
instance over plain HTTP on the LAN/loopback. In production, instances are HTTPS
-and no ATS exception is needed. Mint an invite from the admin console, then paste
-that `…/join/…#k=…` link into the app's join screen.
+and no ATS exception is needed. Mint a key-invite from the admin console, then paste
+that new-format `…/join/<slug>#<T>` link into "Add workspace".
+
+> The webview loads the `web/` assets from the server (`go:embed`), so the embed
+> mode + bridge changes only take effect against a server **built from this branch**.
+> Rebuild/redeploy the binary before testing the app, or the feed will run the old
+> single-thread code and the bridge will be missing.
## How it maps to the server
| iOS file | Responsibility | Web counterpart |
|---|---|---|
-| `Crypto.swift` | AES-GCM via CryptoKit; `iv(12)‖ct‖tag` base64url | `crypto.js` |
-| `LChatAPI.swift` | REST: invite, messages, upload, blob, group | `fetch` calls in `chat.js` |
-| `ChatSocket.swift` | `/ws` send/receive, ping keepalive, reconnect | WebSocket block in `chat.js` |
-| `ChatStore.swift` | decrypt, dedupe, reactions, reply counts | render/state in `chat.js` |
-| `Models.swift` | payload shape + `replySnippet` + reaction set | `format.js`, `hub.go` |
-
-The wire format is byte-identical to the web client in both directions
-(CryptoKit's `SealedBox.combined` is exactly `iv‖ciphertext‖tag`), so iOS and
-browser participants interoperate in the same chat.
+| `Crypto.swift` | AES-GCM + **ECIES key-unwrap** via CryptoKit | `crypto.js` |
+| `LChatAPI.swift` | REST: pubkey, roster, threads, instance-key, messages, group | `fetch` in `chat.js` |
+| `ThreadStore.swift` | unwrap thread keys, decrypt sidebar titles/previews, unread | `loadThreads`/`displayName` in `chat.js` |
+| `LChatApp.swift` | `AppState` (instances) + `NavModel` + the three-pane shell | — |
+| `Views/ChatView.swift` | the embed `WKWebView` + native composer + `WebBridge` | embed hooks in `chat.js` |
+| `Views/JoinView.swift` | add-workspace; delegates join to a webview, captures keypair | `join.js` |
+| `Models.swift` | `SavedInstance`, server JSON, sidebar `Thread`, `previewText` | `db.go` JSON, `chat.js` |
+
+The wire format is byte-identical to the web client (CryptoKit's `SealedBox.combined`
+is exactly `iv‖ciphertext‖tag`; the ECIES wrap is `ephPub(65)‖iv(12)‖ct`, HKDF-SHA256
+salt-empty info `lchat-thread-wrap`). A cross-implementation test vector
+(`LChatTests/thread-key-vector.json`, generated by `test/threadkey.test.mjs`) gates
+the native ECIES against drift — run it with:
+
+```sh
+cd ios && swiftc -parse-as-library LChat/Crypto.swift \
+ LChatTests/ECIESVectorTest.swift -o /tmp/eciesvec \
+ && /tmp/eciesvec LChatTests/thread-key-vector.json
+```
diff --git a/ios/project.yml b/ios/project.yml
index cb8945c..481d755 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -29,7 +29,7 @@ targets:
info:
path: LChat/Info.plist
properties:
- CFBundleDisplayName: lchat
+ CFBundleDisplayName: Eleven
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
@@ -43,3 +43,24 @@ targets:
# test against a `go run .` instance during development.
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
+ # End-to-end UI tests (Simulator only). They need a live server and an invite
+ # link, passed as TEST_RUNNER_INVITE_LINK on the xcodebuild invocation; without
+ # it the test skips itself. See LChatUITests/JoinAndChatTests.swift.
+ LChatUITests:
+ type: bundle.ui-testing
+ platform: iOS
+ sources:
+ - path: LChatUITests
+ dependencies:
+ - target: LChat
+schemes:
+ LChat:
+ build:
+ targets:
+ LChat: all
+ run:
+ config: Debug
+ test:
+ config: Debug
+ targets:
+ - LChatUITests
diff --git a/package.json b/package.json
index 70e0660..78bd5cf 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs"
}
}
diff --git a/test/threadkey.test.mjs b/test/threadkey.test.mjs
new file mode 100644
index 0000000..72c5bb7
--- /dev/null
+++ b/test/threadkey.test.mjs
@@ -0,0 +1,74 @@
+// Cross-implementation gate for ECIES thread-key wrapping. The native iOS client
+// (ios/LChat/Crypto.swift) reimplements unwrapThreadKey in CryptoKit; if the web's
+// crypto.js ever changes the wire format (curve, HKDF salt/info, key encodings),
+// the two silently drift and threads stop opening on one platform. This test pins
+// the format two ways:
+//
+// 1. a fresh wrap → unwrap round-trips in JS (crypto.js self-consistency), and
+// 2. the committed fixture (ios/LChatTests/thread-key-vector.json) still unwraps
+// to its expected key — the SAME fixture the Swift unit test consumes.
+//
+// If (2) fails, crypto.js changed incompatibly: regenerate the fixture (see below)
+// and update Crypto.swift to match in the same change.
+import { readFileSync, writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { generateUserKeypair, wrapThreadKey, unwrapThreadKey, bytesToB64url } from "../web/static/crypto.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const fixturePath = join(here, "..", "ios", "LChatTests", "thread-key-vector.json");
+
+// A known 32-byte thread key (bytes 0..31), so the expected value is stable.
+function knownThreadKeyB64() {
+ const raw = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) raw[i] = i;
+ return bytesToB64url(raw);
+}
+
+function assert(cond, msg) {
+ if (!cond) {
+ console.error("FAIL:", msg);
+ process.exit(1);
+ }
+}
+
+// `node test/threadkey.test.mjs --emit` regenerates the committed fixture (the wrap
+// uses a random ephemeral key, so the fixture must be frozen, not recomputed).
+async function emit() {
+ const expectedThreadKeyB64 = knownThreadKeyB64();
+ const kp = await generateUserKeypair();
+ const wrappedB64 = await wrapThreadKey(expectedThreadKeyB64, kp.publicKey);
+ assert((await unwrapThreadKey(wrappedB64, kp.privateKey)) === expectedThreadKeyB64, "round-trip");
+ const fixture = {
+ _comment:
+ "ECIES thread-key test vector. Generated by test/threadkey.test.mjs from " +
+ "web/static/crypto.js. The Swift LChatTests unwraps wrappedB64 with " +
+ "privPkcs8B64 and must get expectedThreadKeyB64 — a cross-implementation gate " +
+ "so the native ECIES never drifts from the web's. base64url throughout. " +
+ "Regenerate with: node test/threadkey.test.mjs --emit",
+ privPkcs8B64: kp.privateKey,
+ pubRawB64: kp.publicKey,
+ wrappedB64,
+ expectedThreadKeyB64,
+ };
+ writeFileSync(fixturePath, JSON.stringify(fixture, null, 2) + "\n");
+ console.log("wrote", fixturePath);
+}
+
+async function test() {
+ // (1) crypto.js self-consistency.
+ const k = knownThreadKeyB64();
+ const kp = await generateUserKeypair();
+ const wrapped = await wrapThreadKey(k, kp.publicKey);
+ assert((await unwrapThreadKey(wrapped, kp.privateKey)) === k, "fresh wrap/unwrap round-trips");
+
+ // (2) the committed fixture (shared with the Swift test) still unwraps.
+ const fx = JSON.parse(readFileSync(fixturePath, "utf8"));
+ const got = await unwrapThreadKey(fx.wrappedB64, fx.privPkcs8B64);
+ assert(got === fx.expectedThreadKeyB64, "committed fixture unwraps to expected key");
+
+ console.log("threadkey.test.mjs OK");
+}
+
+if (process.argv.includes("--emit")) await emit();
+else await test();
diff --git a/web/static/chat.js b/web/static/chat.js
index 39efa4f..6b05157 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -261,7 +261,29 @@ let typingBeat = null;
let typingIdle = null;
const app = document.getElementById("app");
-const session = loadSession();
+// The native shell (iOS/macOS) injects window.__lchatEmbed at documentStart with
+// everything needed to render ONE thread: {token, name, myUserId, threadId,
+// threadKey, instanceKey?}. In embed mode we render just that thread's feed — the
+// app draws its own sidebar, title bar and composer. The user *private* key is
+// never handed to the webview: native unwraps the thread key and passes only it,
+// so a single thread's page can't reach any other thread's content.
+const embed = window.__lchatEmbed || null;
+const session = embed ? { token: embed.token, name: embed.name } : loadSession();
+
+// Post a message back to the native shell's "lchat" handler (no-op in a browser).
+function nativePost(msg) {
+ try {
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchat) {
+ window.webkit.messageHandlers.lchat.postMessage(msg);
+ }
+ } catch {}
+}
+// Surface a problem to the user. In the webview an alert() would freeze the page
+// (a modal dialog blocks the native message channel), so route errors to the shell.
+function notifyError(msg) {
+ if (embed) nativePost({ error: msg });
+ else alert(msg);
+}
// App state.
let roster = []; // [{user_id, label, public_key}]
@@ -288,6 +310,7 @@ let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a bri
route();
function route() {
+ if (embed) return bootEmbed();
if (!session) return showNoInvite();
if (needsHomeScreen() && localStorage.getItem("lchat_a2hs") !== "done") {
return showA2HS(boot);
@@ -337,6 +360,80 @@ function hideLoading() {
setTimeout(() => el.remove(), 250); // after the CSS fade
}
+// Embed bootstrap: render a single thread's feed for the native shell. Skips the
+// normal roster/instance/thread *unwrap* path (the webview has no user private key)
+// — native already unwrapped this thread's key and handed it over. Everything past
+// that (feed render, send, reactions, edits, typing, attachments) is reused as-is.
+async function bootEmbed() {
+ document.body.classList.add("native-embed");
+ try {
+ myUserId = embed.myUserId || "";
+ await loadRoster(); // token-only; gives sender labels + profile photos
+ if (embed.instanceKey) {
+ try {
+ instanceKeyB64 = embed.instanceKey;
+ instanceKey = await importKey(embed.instanceKey);
+ } catch {}
+ }
+ const tid = embed.threadId;
+ threadKeyB64[tid] = embed.threadKey;
+ threadKeys[tid] = await importKey(embed.threadKey);
+ // Fetch thread objects for kind/members/name_blob, but only the active thread
+ // has a key — the others can't be unwrapped here without the private key.
+ try {
+ const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json();
+ threads = r.threads || [];
+ } catch {
+ threads = [];
+ }
+ if (!threads.find((t) => t.id === tid)) threads.push({ id: tid, kind: "dm", members: [] });
+ threadName[tid] = await displayName(threads.find((t) => t.id === tid));
+ renderApp();
+ await openThread(tid);
+ connectWS();
+ installNativeBridge();
+ startHeartbeat();
+ } catch (e) {
+ console.error(e);
+ nativePost({ error: "Couldn't open this chat." });
+ } finally {
+ hideLoading();
+ }
+}
+
+// The bridge the native composer drives: forward typed text and staged photos into
+// the one crypto/WebSocket path (chat.js). Mirrors the hooks ChatView's WebBridge
+// calls; staged-attachment state + errors are posted back on the "lchat" handler.
+function installNativeBridge() {
+ window.lchatNative = {
+ // Returns whether the text was accepted (socket open + something to send), so
+ // the native field knows whether to clear itself.
+ sendText(text) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
+ const t = (text || "").trim();
+ if (!t && !pending.length) return false;
+ sendMessage(t);
+ return true;
+ },
+ // Stage a photo picked by the native + button (base64, standard alphabet). The
+ // web composer only handles images; anything else is rejected with an error.
+ stageAttachment(att) {
+ try {
+ if (!att || !att.type || !att.type.startsWith("image/")) {
+ nativePost({ error: "Only photos can be attached." });
+ return;
+ }
+ const bin = atob(att.b64);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ addAttachment(new File([bytes], att.name || "photo.jpg", { type: att.type }));
+ } catch {
+ nativePost({ error: "Couldn't attach that photo." });
+ }
+ },
+ };
+}
+
// ---- data ---------------------------------------------------------------
function ensurePubkey() {
@@ -350,8 +447,12 @@ function ensurePubkey() {
async function loadRoster() {
const r = await (await fetch(`/api/roster?token=${encodeURIComponent(session.token)}`)).json();
roster = r.roster || [];
- const me = roster.find((e) => e.public_key === session.userPub);
- myUserId = me ? me.user_id : "";
+ // In embed mode the shell tells us our user id up front (it can't be matched by
+ // public key — the webview never holds one). Otherwise find ourselves by pubkey.
+ if (!myUserId) {
+ const me = roster.find((e) => e.public_key === session.userPub);
+ myUserId = me ? me.user_id : "";
+ }
}
function rosterLabel(userId) {
@@ -835,6 +936,8 @@ function renderApp() {
});
renderThreadList();
+ // Embed mode opens its one thread explicitly (bootEmbed); don't auto-pick here.
+ if (embed) return Promise.resolve();
// Choose the thread to land in, in priority order: a tapped-notification
// deep-link (#thread=), the one we were last in (so a refresh reloads where you
// were), else — desktop only — the first visible chat. Returns the open promise
@@ -2338,7 +2441,10 @@ async function addAttachment(file) {
pending = pending.filter((p) => p !== entry);
renderPreview();
updateSendVisibility();
- alert(isImage ? "Couldn't add that photo. Try a smaller one." : `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`);
+ const msg = isImage ? "Couldn't add that photo. Try a smaller one." : `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).`;
+ // alert() is invisible inside the native shell's webview — post it instead.
+ if (embed) nativePost({ error: msg });
+ else alert(msg);
}
}
@@ -2352,6 +2458,8 @@ async function uploadBytes(bytes) {
}
function renderPreview() {
+ // Tell the native shell an attachment is (un)staged, so its send button enables.
+ if (embed) nativePost({ staged: pending.length > 0 });
const preview = document.getElementById("composer-preview");
preview.textContent = "";
if (!pending.length) {
@@ -2407,6 +2515,7 @@ function removeAttachment(uid) {
function clearPending() {
pending = [];
+ if (embed) nativePost({ staged: false });
const preview = document.getElementById("composer-preview");
if (preview) {
preview.textContent = "";
@@ -2771,7 +2880,10 @@ function connectWS() {
await loadThreads();
}
if (!threadKeys[tid]) {
- // A thread we don't know yet (someone just added us / started a DM).
+ // A thread we don't know yet (someone just added us / started a DM). In embed
+ // mode the native sidebar owns the other threads and we have no private key to
+ // unwrap them, so ignore anything but the active thread.
+ if (embed) return;
await loadThreads();
renderThreadList();
return;
diff --git a/web/static/join.js b/web/static/join.js
index 54ec1c2..6933727 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -20,7 +20,7 @@ init();
function init() {
// Already installed? The link in this URL is likely spent — go to the app,
// which signs in via paste-to-join.
- if (isStandalone) return location.replace("/");
+ if (isStandalone && !window.__lchatEmbed) return location.replace("/");
if (!T) return showError("This link is missing its secret.");
start();
}
@@ -51,7 +51,9 @@ async function start() {
}
if (payload.threadKey) {
// User invite: it drops the joiner into a DM with the person who invited them.
- const existing = loadSession();
+ // In the native embed the shell owns identities (Keychain) — never adopt a
+ // stray webview session; always run the fresh-person flow and hand back.
+ const existing = window.__lchatEmbed ? null : loadSession();
if (existing && existing.token && existing.userPub && existing.userPriv) {
// Already a member on this device → join that DM as your existing self.
showStatus("Opening your chat…");
@@ -101,9 +103,22 @@ async function finish(id, name, pub, priv, wrappedSelf = "") {
body: JSON.stringify({ token: claim.token, public_key: pub }),
});
} catch {}
- localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv }));
- // Land straight in the DM this invite created (the app opens it on boot).
- if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id);
+ if (!window.__lchatEmbed) {
+ localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv }));
+ // Land straight in the DM this invite created (the app opens it on boot).
+ if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id);
+ }
+ if (window.__lchatEmbed) {
+ // Native add-instance flow: hand the resolved identity back to the shell, which
+ // moves the private key into the Keychain. Don't navigate — the shell tears the
+ // one-shot join webview down once it has this.
+ try {
+ window.webkit.messageHandlers.lchat.postMessage({
+ joined: { token: claim.token, name, userPub: pub, userPriv: priv },
+ });
+ } catch {}
+ return;
+ }
location.href = "/"; // drop the fragment from the URL bar
}
diff --git a/web/static/style.css b/web/static/style.css
index 037ddfd..5ae062f 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1316,3 +1316,21 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.tt.show-thread .thread-pane { display: flex; }
.back-btn { display: inline-flex; }
}
+
+/* Native shell (iOS/macOS) embeds ONE thread's feed — the app draws its own
+ sidebar, title bar, and composer. Hide the web sidebar / thread header / composer
+ and let the feed fill the view (overriding the mobile one-pane-at-a-time rules).
+ Gated on body.native-embed, which only chat.js sets when window.__lchatEmbed is
+ injected; browsers are unaffected. */
+.native-embed .sidebar { display: none !important; }
+.native-embed .thread-pane { display: flex !important; }
+.native-embed .thread-head,
+.native-embed .composer,
+.native-embed .composer-reply,
+.native-embed .composer-edit {
+ display: none;
+}
+/* Keep the attachment preview visible: it's how a staged photo shows up just above
+ the native composer (drag-drop or the native + button stage into it). The composer
+ below is hidden, so add the bottom padding it would have provided. */
+.native-embed .composer-preview { padding-bottom: 10px; }