eleven / messenger Public

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>
Paul Campbell 5cae2ff74330d112314401c2d7a97b27e9ff1ffb parent 3ebee4a
4 files changed, +173 −21
  • ios/LChatCore/Sources/LChatCore/SpaceCreate.swift +52 −15
  • ios/LChatMac/AddFromLinkSheet.swift +81 −0
  • ios/LChatMac/LChatMacApp.swift +35 −6
  • ios/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.