eleven / messenger Public

Native invite-someone, iOS message search, Mac focus courtesy (#290)

* mac,core: companion apps' chat view goes native — events project as chat

The Both/Chat panes of a mini-app thread showed an EMPTY native feed:
app events (remind.add, remind.fire, todo.add) aren't type:"chat", so
the fold skipped them all. AppChatProjection is the native twin of the
web apps' chatMessage(): remind.fire renders as the bot's bubble
("You asked me to remind you: …"), remind.add/todo.add as the action
lines you took (with remind.js's absTime format), and unknown events
stay hidden exactly like chatMessage's nulls. Both fold paths route
through the projection, so the store and search index carry the
projected lines too. The app's chatIntro renders above the earliest
message, like the web's start-of-history intro.

The other half of the fix — the right pane showing the app INTERFACE
instead of web chat — is the shell.js embed-view change already on
main; it takes effect when the server deploys.

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

* Invite someone natively; iOS message search; focus returns right; parity rule

- Invite someone (both platforms, iOS first per the new rule):
  InviteCrypto ports deriveInvite/randomSecret/buildInviteFragment
  byte-compatibly (HKDF id/wrap/claim from the secret T; T rides only
  in the link #fragment); ThreadStore.inviteSomeone mirrors the web —
  DM key wrapped to us AND to the invite secret, invitee name
  encrypted under the instance key. iOS: New chat ▸ Invite someone
  (share sheet + copy); Mac: the sidebar ⋮ menu (copyable link). The
  server's "inviting is turned off" surfaces as the error.
- iOS search reaches inside messages: searching swaps the list for
  Conversations + Messages sections, the Messages hits served by the
  local store's FTS (the Mac typeahead's iOS shape). Avatar gains the
  app-glyph rendering the rows needed.
- Mac focus courtesy repaired: on app activation the caret lands in
  the Message field again — the handler existed but every hidden
  instance pane's composer was stealing the write, and it raced the
  ⌘K palette; now only the on-screen pane asserts it, and never over
  an open palette.
- CLAUDE.md: Mac and iOS keep feature parity, iOS first.

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6a22cb0151e674e783d8191cf711a2b756c65c5a parent 8ae3e15
11 files changed, +385 −20
  • CLAUDE.md +5 −0
  • ios/LChat/Views/Avatar.swift +5 −0
  • ios/LChat/Views/InviteSomeoneView.swift +80 −0
  • ios/LChat/Views/NewChatView.swift +12 −0
  • ios/LChat/Views/ThreadListView.swift +93 −18
  • ios/LChatCore/Sources/LChatCore/InviteCrypto.swift +46 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +26 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +27 −0
  • ios/LChatMac/GroupSheets.swift +77 −0
  • ios/LChatMac/NativeChatView.swift +5 −0
  • ios/LChatMac/NativeFeedView.swift +9 −2
diff --git a/CLAUDE.md b/CLAUDE.md
index ac19c11..ecb4461 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -192,6 +192,11 @@ open with the note itself) carry the 🤖 on their first line.
## Conventions
+- 🤖 **Mac and iOS keep feature parity — and iOS comes first.** The two native
+ apps are one product: a feature that lands on one lands on the other in the
+ same change (shared logic in `ios/LChatCore`, thin per-platform UI). When
+ effort must be split or sequenced, build and polish the **iOS** app first —
+ it's the primary surface members live in; the Mac follows.
- **Never `innerHTML` untrusted content.** Build DOM nodes / use `textContent`.
`innerHTML` is only acceptable with static, in-code strings (e.g. `ICON_X`).
- **Never show a toast that floats over UI.** Nudges, warnings, and status
diff --git a/ios/LChat/Views/Avatar.swift b/ios/LChat/Views/Avatar.swift
index bda59c1..d21cdc0 100644
--- a/ios/LChat/Views/Avatar.swift
+++ b/ios/LChat/Views/Avatar.swift
@@ -14,6 +14,8 @@ struct Avatar: View {
// server photo (/api/server-avatar), which isn't an E2E blob so it doesn't go
// through AvatarSource/AvatarLoader. Wins over the fetched `source`/initials.
var photo: UIImage? = nil
+ // An app-thread's emoji glyph (⏰, ✓, …) — replaces the initial.
+ var glyph: String? = nil
@State private var image: UIImage?
@@ -21,6 +23,9 @@ struct Avatar: View {
ZStack {
if let shown = photo ?? image {
Image(uiImage: shown).resizable().scaledToFill()
+ } else if let glyph {
+ Circle().fill(.primary.opacity(0.08))
+ Text(glyph).font(.system(size: size * 0.5))
} else {
Circle().fill(color)
Text(initial)
diff --git a/ios/LChat/Views/InviteSomeoneView.swift b/ios/LChat/Views/InviteSomeoneView.swift
new file mode 100644
index 0000000..5568ac4
--- /dev/null
+++ b/ios/LChat/Views/InviteSomeoneView.swift
@@ -0,0 +1,80 @@
+import LChatCore
+import SwiftUI
+
+// 🤖 Invite a brand-new person straight into a DM (the web's inviteSomeone,
+// natively): type their name, get a join link to share. The link's #fragment
+// carries the one-time secret — it never touches the server, so share it
+// over any channel you trust.
+struct InviteSomeoneView: View {
+ @ObservedObject var store: ThreadStore
+ let onCreated: (String?) -> Void
+
+ @State private var name = ""
+ @State private var busy = false
+ @State private var error: String?
+ @State private var link: String?
+ @State private var copied = false
+
+ var body: some View {
+ List {
+ if let link {
+ Section {
+ Text("Send this link to \(name.trimmingCharacters(in: .whitespaces)) — it's their one-time key into your chat. A new one can be minted later if it's lost.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Text(link)
+ .font(.footnote.monospaced())
+ .lineLimit(3)
+ .textSelection(.enabled)
+ ShareLink(item: URL(string: link)!) {
+ Label("Share invite link", systemImage: "square.and.arrow.up")
+ }
+ Button {
+ UIPasteboard.general.string = link
+ copied = true
+ } label: {
+ Label(copied ? "Copied" : "Copy link",
+ systemImage: copied ? "checkmark" : "doc.on.doc")
+ }
+ } header: {
+ Text("Invite ready")
+ }
+ } else {
+ Section {
+ TextField("Their name", text: $name)
+ .textInputAutocapitalization(.words)
+ .onSubmit(create)
+ Button {
+ create()
+ } label: {
+ if busy { ProgressView() } else { Text("Create invite") }
+ }
+ .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
+ if let error {
+ Text(error).font(.footnote).foregroundStyle(.red)
+ }
+ } footer: {
+ Text("Creates a pending chat with them and a join link only they can use.")
+ }
+ }
+ }
+ .navigationTitle("Invite someone")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+
+ private func create() {
+ let trimmed = name.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty, !busy else { return }
+ busy = true
+ error = nil
+ Task {
+ let r = await store.inviteSomeone(name: trimmed)
+ busy = false
+ if let l = r.link {
+ link = l
+ } else {
+ error = r.error ?? "Couldn't create the invite."
+ }
+ }
+ }
+}
diff --git a/ios/LChat/Views/NewChatView.swift b/ios/LChat/Views/NewChatView.swift
index 4e5fa6b..92097ca 100644
--- a/ios/LChat/Views/NewChatView.swift
+++ b/ios/LChat/Views/NewChatView.swift
@@ -32,6 +32,18 @@ struct NewChatView: View {
.foregroundStyle(Color.accentColor)
}
}
+ // Bring someone new onto the server (if the admin allows
+ // member invites — the server says no otherwise).
+ NavigationLink {
+ InviteSomeoneView(store: store) { _ in dismiss() }
+ } label: {
+ Label {
+ Text("Invite someone").font(.body.weight(.medium))
+ } icon: {
+ Image(systemName: "person.badge.plus")
+ .foregroundStyle(Color.accentColor)
+ }
+ }
}
if others.isEmpty {
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index cf6eeff..287d649 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -34,27 +34,31 @@ struct ThreadListView: View {
var body: some View {
// A UIKit UICollectionView list, not SwiftUI's List — see ThreadListCollection
// for why (stock UITableView row highlight that SwiftUI's List can't match).
- ThreadListCollection(
- threads: visibleThreads,
- lockScroll: lockScroll,
- onSelect: { path.append($0) },
- onClose: { store.setHidden($0, true) },
- onPin: { store.setPinned($0, !$0.pinned) },
- onToggleUnread: { $0.unread ? store.markRead($0) : store.markUnread($0) },
- onRefresh: { await load() }
- )
- // Extend under the nav bar (and home indicator) so the list scrolls *behind*
- // the frosted bar; the collection view's automatic content inset still rests
- // the first row below the bar, and setContentScrollView frosts it once scrolled.
- .ignoresSafeArea(edges: [.top, .bottom])
+ Group {
+ if search.trimmingCharacters(in: .whitespaces).isEmpty {
+ ThreadListCollection(
+ threads: visibleThreads,
+ lockScroll: lockScroll,
+ onSelect: { path.append($0) },
+ onClose: { store.setHidden($0, true) },
+ onPin: { store.setPinned($0, !$0.pinned) },
+ onToggleUnread: { $0.unread ? store.markRead($0) : store.markUnread($0) },
+ onRefresh: { await load() }
+ )
+ // Extend under the nav bar (and home indicator) so the list scrolls
+ // *behind* the frosted bar; the collection view's automatic content
+ // inset still rests the first row below the bar.
+ .ignoresSafeArea(edges: [.top, .bottom])
+ } else {
+ // Searching swaps in the results view (the Mac typeahead's iOS
+ // shape): conversations by title, then full-text message hits
+ // from the local store.
+ searchResults
+ }
+ }
.searchable(text: $search, prompt: "Search chats")
.overlay { if store.loading && store.threads.isEmpty { ProgressView() } }
.overlay { if !store.loading && store.threads.isEmpty { empty } }
- .overlay {
- if !store.threads.isEmpty && visibleThreads.isEmpty {
- ContentUnavailableView.search(text: search)
- }
- }
.navigationTitle(serverTitle) // backs the chat screen's back button
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -100,6 +104,77 @@ struct ThreadListView: View {
.task(id: store.instance.id) { await load() }
}
+ // ---- search results (conversations + message full-text) -----------------
+
+ @State private var searchHits: [(thread: String, message: MessageStore.StoredMessage)] = []
+
+ private var messageMatches: [(thread: ChatThread, message: MessageStore.StoredMessage)] {
+ searchHits.compactMap { hit in
+ store.threads.first { $0.id == hit.thread }.map { ($0, hit.message) }
+ }
+ }
+
+ private var searchResults: some View {
+ List {
+ if !visibleThreads.isEmpty {
+ Section("Conversations") {
+ ForEach(visibleThreads) { t in
+ Button {
+ path.append(t.id)
+ } label: {
+ HStack(spacing: 10) {
+ Avatar(name: t.title, size: 34, source: t.avatar, glyph: t.glyph)
+ Text(t.title).font(.body.weight(.medium)).lineLimit(1)
+ if let badge = t.badge { AgentBadge(badge) }
+ }
+ }
+ .foregroundStyle(.primary)
+ }
+ }
+ }
+ if !messageMatches.isEmpty {
+ Section("Messages") {
+ ForEach(Array(messageMatches.enumerated()), id: \.offset) { _, hit in
+ Button {
+ path.append(hit.thread.id)
+ } label: {
+ HStack(spacing: 10) {
+ Avatar(name: hit.thread.title, size: 34,
+ source: hit.thread.avatar, glyph: hit.thread.glyph)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(hit.thread.title)
+ .font(.subheadline.weight(.medium)).lineLimit(1)
+ Text((hit.message.payload.name.map { "\($0): " } ?? "")
+ + (hit.message.payload.text ?? ""))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ }
+ .foregroundStyle(.primary)
+ }
+ }
+ }
+ if visibleThreads.isEmpty && messageMatches.isEmpty {
+ ContentUnavailableView.search(text: search)
+ .listRowBackground(Color.clear)
+ }
+ }
+ .listStyle(.insetGrouped)
+ // Debounced FTS against the local store — decrypted once at ingest,
+ // exactly what the SQLite proposal bought us.
+ .task(id: search) {
+ let q = search.trimmingCharacters(in: .whitespaces)
+ guard !q.isEmpty else { searchHits = []; return }
+ try? await Task.sleep(nanoseconds: 120_000_000)
+ guard !Task.isCancelled else { return }
+ let escaped = "\"" + q.replacingOccurrences(of: "\"", with: "") + "\"*"
+ searchHits = Array(await MessageStore.shared
+ .search(instance: store.instance.id, query: escaped, limit: 12))
+ }
+ }
+
private var empty: some View {
VStack(spacing: 8) {
Image(systemName: "tray").font(.system(size: 36)).foregroundStyle(.secondary)
diff --git a/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift b/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift
new file mode 100644
index 0000000..c0763f5
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/InviteCrypto.swift
@@ -0,0 +1,46 @@
+import Foundation
+import CryptoKit
+
+// 🤖 The invite-link key derivation, byte-compatible with web/static/crypto.js
+// (deriveInvite / randomSecret) and invite.js (buildInviteFragment): the
+// secret T rides ONLY in the link's #fragment; the server stores the derived
+// lookup id + claim hash + wrapped blobs it can't open. See
+// docs/invite-redesign.md.
+public enum InviteCrypto {
+ public struct Derived {
+ public let id: String // b64url HKDF("lchat-invite-id")
+ public let wrapKey: GroupKey // AES-GCM from HKDF("lchat-invite-wrap")
+ public let claimHash: String // lowercase hex SHA-256 of the claim secret
+ }
+
+ /// A fresh 128-bit invite secret (crypto.js randomSecret).
+ public static func randomSecret() -> String {
+ var bytes = [UInt8](repeating: 0, count: 16)
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ return Data(bytes).base64urlEncodedString()
+ }
+
+ public static func derive(_ tB64: String) throws -> Derived {
+ guard let t = Data(base64urlEncoded: tB64) else { throw CryptoError.badBlob }
+ let ikm = SymmetricKey(data: t)
+ func bits(_ info: String) -> Data {
+ HKDF<SHA256>.deriveKey(inputKeyMaterial: ikm, salt: Data(),
+ info: Data(info.utf8), outputByteCount: 32)
+ .withUnsafeBytes { Data($0) }
+ }
+ let claimSecret = bits("lchat-invite-claim").base64urlEncodedString()
+ let claimHash = SHA256.hash(data: Data(claimSecret.utf8))
+ .map { String(format: "%02x", $0) }.joined()
+ return Derived(id: bits("lchat-invite-id").base64urlEncodedString(),
+ wrapKey: try GroupKey(rawKey: bits("lchat-invite-wrap")),
+ claimHash: claimHash)
+ }
+
+ /// invite.js buildInviteFragment: "e1.<T>" or "e1.<T>~<encoded name>".
+ public static func fragment(_ tB64: String, name: String) -> String {
+ var allowed = CharacterSet.alphanumerics
+ allowed.insert(charactersIn: "-_.!~*'()") // encodeURIComponent's survivors
+ let encoded = name.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
+ return "e1." + tB64 + (encoded.isEmpty ? "" : "~" + encoded)
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index ddcdb4a..0444cf1 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -150,6 +150,32 @@ public struct LChatAPI {
return try JSONDecoder().decode(T.self, from: data)
}
+ /// Create a member invite (a pending DM + join link): all blobs are
+ /// opaque to the server. Returns (thread_id, nil) or (nil, error text —
+ /// e.g. the admin has inviting turned off).
+ public func createUserInvite(labelBlob: String, id: String, wrapped: String,
+ selfWrapped: String, claimHash: String)
+ async -> (threadID: String?, error: String?) {
+ guard let url = URL(string: "api/invites", relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: [
+ "token": token, "label_blob": labelBlob, "id": id,
+ "wrapped": wrapped, "self_wrapped": selfWrapped,
+ "claim_hash": claimHash,
+ ]) else { return (nil, "bad request") }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ guard let (body, resp) = try? await session.data(for: req),
+ let http = resp as? HTTPURLResponse else { return (nil, "network error") }
+ struct R: Codable { var thread_id: String?; var error: String? }
+ let r = try? JSONDecoder().decode(R.self, from: body)
+ guard http.statusCode == 200, let tid = r?.thread_id else {
+ return (nil, r?.error ?? "invite failed")
+ }
+ return (tid, nil)
+ }
+
/// Publish the caller's display name — encrypted under the instance key
/// (the server blanks the plaintext label once one is set this way).
@discardableResult
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 0ba766c..f4d122a 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -315,6 +315,33 @@ public final class ThreadStore: ObservableObject {
return added
}
+ /// Invite a brand-new person straight into a DM (the web's inviteSomeone):
+ /// generate the DM key, wrap it to US (so we hold it) and to the invite
+ /// secret (so the joiner recovers it); the server stores only blobs. The
+ /// secret rides ONLY in the returned link's #fragment.
+ public func inviteSomeone(name: String) async -> (link: String?, error: String?) {
+ guard let api else { return (nil, "not connected") }
+ guard let ikB64 = instanceKeyB64, let ik = try? GroupKey(base64url: ikB64) else {
+ return (nil, "still setting up — try again in a moment")
+ }
+ let t = InviteCrypto.randomSecret()
+ let raw = ECIES.randomAESKey()
+ let b64 = raw.base64urlEncodedString()
+ guard let derived = try? InviteCrypto.derive(t),
+ let selfWrapped = try? ECIES.wrap(rawKey: raw, recipientPubB64: instance.userPub),
+ let wrapped = try? derived.wrapKey.encryptJSON(["threadKey": b64]),
+ let labelBlob = try? ik.encryptJSON(name) else {
+ return (nil, "couldn't build the invite")
+ }
+ let r = await api.createUserInvite(labelBlob: labelBlob, id: derived.id,
+ wrapped: wrapped, selfWrapped: selfWrapped,
+ claimHash: derived.claimHash)
+ guard r.threadID != nil else { return (nil, r.error) }
+ await refresh()
+ let link = instance.origin + "/join/join#" + InviteCrypto.fragment(t, name: name)
+ return (link, nil)
+ }
+
/// Change your display name across the instance: encrypted under the
/// instance key (the web's publishMyLabel). Callers should also persist
/// the new name into the SavedInstance so outgoing payloads carry it.
diff --git a/ios/LChatMac/GroupSheets.swift b/ios/LChatMac/GroupSheets.swift
index b3e7ba3..a7ba13b 100644
--- a/ios/LChatMac/GroupSheets.swift
+++ b/ios/LChatMac/GroupSheets.swift
@@ -105,6 +105,83 @@ struct MembersSheet: View {
}
}
+// Invite a brand-new person straight into a DM (the web's inviteSomeone):
+// type their name, get the one-time join link (its #fragment carries the
+// secret — never sent to the server).
+struct InviteSomeoneSheet: View {
+ let store: ThreadStore
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var name = ""
+ @State private var busy = false
+ @State private var error: String?
+ @State private var link: String?
+ @State private var copied = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Invite someone").font(.system(size: 14, weight: .semibold))
+ Spacer()
+ Button(link == nil ? "Cancel" : "Done") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ }
+ if let link {
+ Text("Send this link to \(name.trimmingCharacters(in: .whitespaces)) — it's their one-time key into your chat.")
+ .font(.system(size: 12)).foregroundStyle(.secondary)
+ Text(link)
+ .font(.system(size: 11).monospaced())
+ .lineLimit(3)
+ .textSelection(.enabled)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 8).fill(.primary.opacity(0.05)))
+ Button {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(link, forType: .string)
+ copied = true
+ } label: {
+ Label(copied ? "Copied" : "Copy link",
+ systemImage: copied ? "checkmark" : "doc.on.doc")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ } else {
+ TextField("Their name", text: $name)
+ .textFieldStyle(.roundedBorder)
+ .onSubmit(create)
+ if let error {
+ Text(error).font(.system(size: 12)).foregroundStyle(.red)
+ }
+ Button(action: create) {
+ if busy {
+ ProgressView().controlSize(.small).frame(maxWidth: .infinity)
+ } else {
+ Text("Create invite").frame(maxWidth: .infinity)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ }
+ .padding(14)
+ .frame(width: 340)
+ .tint(ChatTheme.classic.accent.light)
+ }
+
+ private func create() {
+ let trimmed = name.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty, !busy else { return }
+ busy = true
+ error = nil
+ Task {
+ let r = await store.inviteSomeone(name: trimmed)
+ busy = false
+ if let l = r.link { link = l } else { error = r.error ?? "Couldn't create the invite." }
+ }
+ }
+}
+
struct RenameGroupSheet: View {
let store: ThreadStore
let thread: ChatThread
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index caeb44b..d96e518 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -28,6 +28,7 @@ struct NativeChatView: View {
@State private var search = ""
@State private var showNewChat = false
+ @State private var showInvite = false
// The list is user-resizable (drag the divider); below the threshold it
// collapses to an avatar-only rail. Width persists across launches.
@AppStorage("macChatListWidth") private var listWidth = 300.0
@@ -89,6 +90,9 @@ struct NativeChatView: View {
.sheet(item: $renameSheetThread) { t in
RenameGroupSheet(store: store, thread: t)
}
+ .sheet(isPresented: $showInvite) {
+ InviteSomeoneSheet(store: store)
+ }
.onChange(of: app.nativeOpenThread) { _, id in
guard isActive, let id else { return }
// "user:"/"app:" rows have no thread yet — create (the server
@@ -483,6 +487,7 @@ struct NativeChatView: View {
requestNotifications()
}
Divider()
+ Button("Invite Someone…") { showInvite = true }
Button("Refresh") { Task { await store.refresh() } }
} label: {
Image(systemName: "ellipsis")
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 0544c4c..f64d30c 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -780,10 +780,17 @@ struct NativeFeedView: View {
}
.focused($composerFocused)
// Regaining the app drops the caret into the message box, the
- // same courtesy the webview shell extends (focusComposer).
+ // same courtesy the webview shell extends (focusComposer) —
+ // but ONLY from the pane actually on screen (every instance's
+ // pane stays mounted at opacity 0, and their writes were
+ // stealing focus from the visible composer), and never over
+ // an open ⌘K palette (its own handler re-asserts its field).
.onReceive(NotificationCenter.default.publisher(
for: NSApplication.didBecomeActiveNotification)) { _ in
- composerFocused = true
+ guard isActive, !app.showPalette else { return }
+ DispatchQueue.main.async {
+ if !app.showPalette { composerFocused = true }
+ }
}
// Focus on open — asserted again a beat later AND when the
// feed reveals: a FocusState write in onAppear races the