Mac: Spaces menu + native add-from-link (#504)
* mac: Spaces menu (favourites + More, create + add from link) and the native paste-a-link claim The menu-bar Servers menu becomes Spaces and mirrors the rail: favourites (else the first nine active) on ⌘1–9, the rest under More, then 'Create a space' and 'Add from link…'. The sheet claims ANY invite link natively — SpaceClaim grows the two remaining join.js shapes (DM invites re-wrap the thread key to the fresh keypair; add-device links adopt the carried identity, taking the fragment's ~name) — and addInstance syncs the joined space to the member's Eleven account. Sentence case per Paul (noted as a standing rule): title case only with strong justification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: Esc closes the create takeover via the key monitor The invisible cancel-action button lost to the webview first responder (the same failure that once ate a view-level ⌘F). Esc now routes through the AppDelegate NSEvent monitor, gated on the takeover being up and never mid-create; it falls through untouched otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * core+mac: requestCancel — the Esc route needed a public door Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+173
−21
ios/LChatCore/Sources/LChatCore/SpaceCreate.swift+52 −15ios/LChatMac/AddFromLinkSheet.swift+81 −0ios/LChatMac/LChatMacApp.swift+35 −6ios/LChatMac/RootView.swift+5 −0
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift| index 50322a7..f1fa749 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift |
| @@ -74,24 +74,27 @@ public enum SpaceCreationFlow { |
| public enum SpaceClaim { |
| static let cantReach = "Couldn't reach your new space — check your connection and try again." |
| - /// Claim a `/join/setup#<T>` link natively and stand the space up for this |
| - /// device: device token, fresh identity keypair, registered pubkey, minted |
| - /// instance key, published label. Returns a ready-to-add SavedInstance |
| - /// (myUserId resolved when the roster already lists us; else it resolves |
| - /// on first thread-list load, like any instance). |
| + /// Claim any `/join/…#<T>` link natively and stand the space up for this |
| + /// device — all three invite shapes join.js handles, no webview: |
| + /// - setup / generic member invite (no wrapped payload): fresh keypair; |
| + /// - DM invite ({threadKey}): fresh keypair, the thread key re-wrapped to |
| + /// it and sent with the claim (the server stores our copy); |
| + /// - add-device ({priv, pub}): the carried identity is adopted verbatim. |
| + /// Returns a ready-to-add SavedInstance (myUserId resolved when the roster |
| + /// already lists us; else on first thread-list load, like any instance). |
| public static func claim(setupLink: String, screenName: String) async throws -> SavedInstance { |
| guard let (origin, joinURL) = parseJoinLink(setupLink), |
| let base = URL(string: origin), |
| let t = inviteSecret(fromFragment: joinURL.fragment ?? ""), |
| let derived = try? InviteCrypto.derive(t) else { |
| - throw SpaceClaimError("That setup link doesn't look right.") |
| + throw SpaceClaimError("That link doesn't look right.") |
| } |
| // The invite must still be live (single-use; the server only ever sees |
| // the derived id, never T). A network failure is a network failure — |
| // never misreported as "expired" (the retry-able and the dead-end are |
| // different problems). |
| - struct InviteInfo: Decodable { let ok: Bool?; let grants_admin: Bool? } |
| + struct InviteInfo: Decodable { let ok: Bool?; let wrapped: String?; let grants_admin: Bool? } |
| let info: InviteInfo |
| do { |
| info = try await getJSON(base.appending(path: "/api/key-invite/\(derived.id)")) |
| @@ -99,27 +102,52 @@ public enum SpaceClaim { |
| throw SpaceClaimError(cantReach) |
| } |
| guard info.ok == true else { |
| - throw SpaceClaimError("This setup link has expired or was already used.") |
| + throw SpaceClaimError("This link has expired or was already used.") |
| } |
| - // Claim: proof-of-possession is the derived claim secret; a setup |
| - // invite ships no wrapped key, so `wrapped` rides empty. |
| + // The wrapped payload (when present) decides the shape. |
| + struct InvitePayload: Decodable { let priv: String?; let pub: String?; let threadKey: String? } |
| + var keypair: (pub: String, priv: String) |
| + var wrappedSelf = "" // a DM invite's thread key, re-wrapped to us |
| + var suggested = "" // add-device links carry the name in the fragment |
| + if let w = info.wrapped, !w.isEmpty { |
| + guard let payload = try? derived.wrapKey.decryptJSON(InvitePayload.self, blob: w) else { |
| + throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).") |
| + } |
| + if let priv = payload.priv, let pub = payload.pub { |
| + // Add-device: sign in as the existing identity. |
| + keypair = (pub, priv) |
| + suggested = suggestedName(fromFragment: joinURL.fragment ?? "") ?? "Me" |
| + } else if let tk = payload.threadKey, let raw = Data(base64urlEncoded: tk) { |
| + // DM invite: fresh identity, our copy of the DM key rides with |
| + // the claim (join.js wrapThreadKey(threadKey, kp.publicKey)). |
| + keypair = ECIES.generateUserKeypair() |
| + wrappedSelf = (try? ECIES.wrap(rawKey: raw, recipientPubB64: keypair.pub)) ?? "" |
| + guard !wrappedSelf.isEmpty else { |
| + throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).") |
| + } |
| + } else { |
| + throw SpaceClaimError("Couldn't unlock this link (wrong or corrupt secret).") |
| + } |
| + } else { |
| + keypair = ECIES.generateUserKeypair() |
| + } |
| + |
| + // Claim: proof-of-possession is the derived claim secret. |
| struct ClaimResp: Decodable { let ok: Bool?; let token: String? } |
| let claim: ClaimResp |
| do { |
| claim = try await postJSON( |
| base.appending(path: "/api/key-invite/\(derived.id)/claim"), |
| - body: ["wrapped": "", "claim_secret": derived.claimSecret]) |
| + body: ["wrapped": wrappedSelf, "claim_secret": derived.claimSecret]) |
| } catch { |
| throw SpaceClaimError(cantReach) |
| } |
| guard claim.ok == true, let token = claim.token, !token.isEmpty else { |
| - throw SpaceClaimError("Couldn't claim your space — the setup link may already be used.") |
| + throw SpaceClaimError("Couldn't claim this link — it may already be used.") |
| } |
| - // Fresh identity: the private key exists only here (Keychain-bound via |
| - // SavedInstance); the server learns just the public half. |
| - let keypair = ECIES.generateUserKeypair() |
| + let screenName = screenName.isEmpty ? suggested : screenName |
| let api = LChatAPI(baseURL: base, token: token) |
| await api.ensurePubkey(keypair.pub) |
| @@ -158,6 +186,15 @@ public enum SpaceClaim { |
| return f.isEmpty ? nil : f |
| } |
| + /// The "~<encoded name>" tail some links carry (invite.js: the inviter's |
| + /// suggestion / an add-device link's owner) — never sent to any server. |
| + static func suggestedName(fromFragment fragment: String) -> String? { |
| + guard let tilde = fragment.firstIndex(of: "~") else { return nil } |
| + let raw = String(fragment[fragment.index(after: tilde)...]) |
| + let name = raw.removingPercentEncoding ?? raw |
| + return name.isEmpty ? nil : name |
| + } |
| + |
| private static func getJSON<T: Decodable>(_ url: URL) async throws -> T { |
| let (data, _) = try await URLSession.shared.data(from: url) |
| return try JSONDecoder().decode(T.self, from: data) |
diff --git a/ios/LChatMac/AddFromLinkSheet.swift b/ios/LChatMac/AddFromLinkSheet.swift| new file mode 100644 |
| index 0000000..fa4a39b |
| --- /dev/null |
| +++ b/ios/LChatMac/AddFromLinkSheet.swift |
| @@ -0,0 +1,81 @@ |
| +import LChatCore |
| +import SwiftUI |
| + |
| +// 🤖 Spaces ▸ Add from Link…: paste any Eleven invite link and the space joins |
| +// this Mac NATIVELY — SpaceClaim handles all three link shapes (member/setup |
| +// invites, DM invites, add-device links) with no webview; the secret in the |
| +// #fragment never reaches any server. addInstance persists the identity in |
| +// the Keychain, selects the space, and pushes it to the member's Eleven |
| +// account (home vault), so it follows them to every signed-in device. |
| +struct AddFromLinkSheet: View { |
| + @EnvironmentObject var app: MacAppState |
| + @Environment(\.dismiss) private var dismiss |
| + @State private var link = "" |
| + @State private var busy = false |
| + @State private var error: String? |
| + |
| + var body: some View { |
| + VStack(alignment: .leading, spacing: 12) { |
| + Text("Add a space from a link") |
| + .font(.headline) |
| + Text("Paste an invite link — the space joins this Mac and syncs to your Eleven account.") |
| + .font(.callout) |
| + .foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + TextField("https://…/join/…", text: $link) |
| + .textFieldStyle(.roundedBorder) |
| + .disabled(busy) |
| + .onSubmit(add) |
| + if let error { |
| + Text(error) |
| + .font(.footnote) |
| + .foregroundStyle(.red) |
| + .fixedSize(horizontal: false, vertical: true) |
| + } |
| + HStack { |
| + Button("Cancel") { dismiss() } |
| + .keyboardShortcut(.cancelAction) |
| + .disabled(busy) |
| + Spacer() |
| + Button { |
| + add() |
| + } label: { |
| + HStack(spacing: 6) { |
| + if busy { ProgressView().controlSize(.small) } |
| + Text(busy ? "Joining…" : "Add Space") |
| + } |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .keyboardShortcut(.defaultAction) |
| + .disabled(busy || parseJoinLink(link.trimmingCharacters(in: .whitespacesAndNewlines)) == nil) |
| + } |
| + } |
| + .padding(20) |
| + .frame(width: 440) |
| + } |
| + |
| + private func add() { |
| + let trimmed = link.trimmingCharacters(in: .whitespacesAndNewlines) |
| + guard !busy, parseJoinLink(trimmed) != nil else { |
| + if !trimmed.isEmpty { error = "That doesn't look like an Eleven invite link." } |
| + return |
| + } |
| + busy = true |
| + error = nil |
| + Task { @MainActor in |
| + do { |
| + // Carry the name you already go by; an add-device link's own |
| + // "~name" (or "Me") wins inside the claim when this is empty. |
| + let screenName = app.instancesInRailOrder().first(where: { !$0.isArchived })?.screenName |
| + ?? app.instances.first?.screenName ?? "" |
| + let inst = try await SpaceClaim.claim(setupLink: trimmed, screenName: screenName) |
| + app.addInstance(inst) // Keychain + rail + select + vault push |
| + dismiss() |
| + } catch { |
| + self.error = (error as? LocalizedError)?.errorDescription |
| + ?? "Couldn't add that space." |
| + busy = false |
| + } |
| + } |
| + } |
| +} |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index eb0274b..a284f1d 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -68,7 +68,7 @@ struct LChatMacApp: App { |
| // File → New Space: the goes-to-eleven create flow (the rail's |
| // "+"). ⇧⌘N, the new-container idiom. The Sidebar owns the |
| // sheet; it opens on the tick. |
| - Button("New Space") { app.requestCreateSpace() } |
| + Button("New space") { app.requestCreateSpace() } |
| .keyboardShortcut("n", modifiers: [.command, .shift]) |
| .disabled(app.instances.isEmpty) |
| } |
| @@ -103,15 +103,27 @@ struct LChatMacApp: App { |
| .keyboardShortcut("0", modifiers: .command) |
| Divider() |
| } |
| - // ⌘1…⌘9 jump straight to a server, like browser/Slack tabs. |
| - CommandMenu("Servers") { |
| - Button("Quick Switch…") { app.showPalette = true } |
| + // ⌘1…⌘9 jump straight to a space, like browser/Slack tabs. The |
| + // menu mirrors the rail: favourites (else the first nine active), |
| + // the rest under More, and the two ways a space arrives. |
| + CommandMenu("Spaces") { |
| + Button("Quick switch…") { app.showPalette = true } |
| .keyboardShortcut("k", modifiers: .command) |
| Divider() |
| - ForEach(Array(app.chats.prefix(9).enumerated()), id: \.element.id) { index, chat in |
| + ForEach(Array(app.menuSpaces.enumerated()), id: \.element.id) { index, chat in |
| Button(app.serverName(chat)) { app.selection = chat.id } |
| .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) |
| } |
| + if !app.menuOverflow.isEmpty { |
| + Menu("More") { |
| + ForEach(app.menuOverflow) { chat in |
| + Button(app.serverName(chat)) { app.selection = chat.id } |
| + } |
| + } |
| + } |
| + Divider() |
| + Button("Create a space") { app.requestCreateSpace() } |
| + Button("Add from link…") { app.requestAddFromLink() } |
| } |
| // Sits right below "About Eleven" in the app menu. |
| CommandGroup(after: .appInfo) { |
| @@ -223,10 +235,27 @@ final class MacAppState: ObservableObject { |
| // observes it. |
| @Published var cycleChatTick = 0 |
| // File → New Space (and anything else app-level) asks for the create flow |
| - // by bumping this; the Sidebar owns the sheet and opens on the tick. |
| + // by bumping this; RootView owns the takeover and opens on the tick. |
| @Published var createSpaceTick = 0 |
| + // Spaces → Add from link…: RootView owns the paste sheet. |
| + @Published var addFromLinkTick = 0 |
| func requestCreateSpace() { createSpaceTick += 1 } |
| + func requestAddFromLink() { addFromLinkTick += 1 } |
| + |
| + // The Spaces menu's rail mirror: favourites when any exist, else the first |
| + // nine active spaces (⌘1…⌘9 follow this order); the rest fold under More. |
| + var menuSpaces: [SavedChat] { |
| + let active = chats.filter { !isArchived($0) } |
| + let starred = active.filter { isStarred($0) } |
| + return starred.isEmpty ? Array(active.prefix(9)) : Array(starred.prefix(9)) |
| + } |
| + |
| + var menuOverflow: [SavedChat] { |
| + let active = chats.filter { !isArchived($0) } |
| + let shown = Set(menuSpaces.map(\.id)) |
| + return active.filter { !shown.contains($0.id) } |
| + } |
| // A "open this thread AND land on this message" request (search hits): |
| // the matching feed consumes it once its history contains the target. |
| @Published var nativeJump: NativeJumpTarget? |
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift| index 5633ac7..38da5f1 100644 |
| --- a/ios/LChatMac/RootView.swift |
| +++ b/ios/LChatMac/RootView.swift |
| @@ -20,6 +20,8 @@ struct RootView: View { |
| // The create-a-space takeover (the rail's + / File ▸ New Space, via |
| // app.createSpaceTick). Non-nil = the instrument owns the window. |
| @State private var createModel: CreateSpaceModel? |
| + // Spaces ▸ Add from link… (via app.addFromLinkTick). |
| + @State private var showAddFromLink = false |
| // Esc-to-close for the takeover, installed only while it's up. A local |
| // NSEvent monitor, not a SwiftUI key hook: the webview behind still holds |
| // first responder, so .onKeyPress/.onExitCommand never fire here. |
| @@ -93,6 +95,9 @@ struct RootView: View { |
| } |
| .animation(.easeOut(duration: 0.18), value: createModel == nil) |
| .onChange(of: app.createSpaceTick) { _, _ in createModel = makeCreateModel() } |
| + // Spaces ▸ Add from link… — the native paste-a-link claim. |
| + .onChange(of: app.addFromLinkTick) { _, _ in showAddFromLink = true } |
| + .sheet(isPresented: $showAddFromLink) { AddFromLinkSheet() } |
| // ⌘K quick-switch palette, drawn over the whole window (above the web feed). |
| .overlay { if app.showPalette { CommandPalette() } } |
| // The photo / video lightboxes, zooming out of their bubble over black. |