| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | |
| 4 | // 🤖 Spaces ▸ Add from Link…: paste any Eleven invite link and the space joins |
| 5 | // this Mac NATIVELY — SpaceClaim handles all three link shapes (member/setup |
| 6 | // invites, DM invites, add-device links) with no webview; the secret in the |
| 7 | // #fragment never reaches any server. addInstance persists the identity in |
| 8 | // the Keychain, selects the space, and pushes it to the member's Eleven |
| 9 | // account (home vault), so it follows them to every signed-in device. |
| 10 | struct AddFromLinkSheet: View { |
| 11 | @EnvironmentObject var app: MacAppState |
| 12 | @Environment(\.dismiss) private var dismiss |
| 13 | @State private var link = "" |
| 14 | @State private var busy = false |
| 15 | @State private var error: String? |
| 16 | @State private var hint: String? |
| 17 | |
| 18 | /// The browser handoff's pre-fill (onOpenURL stashes the validated link; |
| 19 | /// see LChatMacApp): consumed once, so a later manual open starts blank. |
| 20 | /// The locked pane's rungs stash contextual guidance the same way. |
| 21 | private func consumePendingLink() { |
| 22 | if let pending = app.pendingAddLink { |
| 23 | link = pending |
| 24 | app.pendingAddLink = nil |
| 25 | } |
| 26 | if let h = app.addFromLinkHint { |
| 27 | hint = h |
| 28 | app.addFromLinkHint = nil |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | var body: some View { |
| 33 | VStack(alignment: .leading, spacing: 12) { |
| 34 | Text("Add a space from a link") |
| 35 | .font(.headline) |
| 36 | Text(hint ?? "Paste an invite link — the space joins this Mac and syncs to your Eleven account.") |
| 37 | .font(.callout) |
| 38 | .foregroundStyle(.secondary) |
| 39 | .fixedSize(horizontal: false, vertical: true) |
| 40 | TextField("https://…/join/…", text: $link) |
| 41 | .textFieldStyle(.roundedBorder) |
| 42 | .disabled(busy) |
| 43 | .onSubmit(add) |
| 44 | if let error { |
| 45 | Text(error) |
| 46 | .font(.footnote) |
| 47 | .foregroundStyle(.red) |
| 48 | .fixedSize(horizontal: false, vertical: true) |
| 49 | } |
| 50 | HStack { |
| 51 | Button("Cancel") { dismiss() } |
| 52 | .keyboardShortcut(.cancelAction) |
| 53 | .disabled(busy) |
| 54 | Spacer() |
| 55 | Button { |
| 56 | add() |
| 57 | } label: { |
| 58 | HStack(spacing: 6) { |
| 59 | if busy { ProgressView().controlSize(.small) } |
| 60 | Text(busy ? "Joining…" : "Add Space") |
| 61 | } |
| 62 | } |
| 63 | .buttonStyle(.borderedProminent) |
| 64 | .keyboardShortcut(.defaultAction) |
| 65 | .disabled(busy || parseJoinLink(link.trimmingCharacters(in: .whitespacesAndNewlines)) == nil) |
| 66 | } |
| 67 | } |
| 68 | .padding(20) |
| 69 | .frame(width: 440) |
| 70 | .onAppear(perform: consumePendingLink) |
| 71 | // A handoff can land while the sheet is already up (a second click |
| 72 | // in the browser) — take the newest link in place. |
| 73 | .onChange(of: app.pendingAddLink) { _, pending in |
| 74 | if pending != nil, !busy { consumePendingLink() } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | private func add() { |
| 79 | let trimmed = link.trimmingCharacters(in: .whitespacesAndNewlines) |
| 80 | guard !busy, parseJoinLink(trimmed) != nil else { |
| 81 | if !trimmed.isEmpty { error = "That doesn't look like an Eleven invite link." } |
| 82 | return |
| 83 | } |
| 84 | busy = true |
| 85 | error = nil |
| 86 | Task { @MainActor in |
| 87 | do { |
| 88 | // Carry the name you already go by; an add-device link's own |
| 89 | // "~name" (or "Me") wins inside the claim when this is empty. |
| 90 | let screenName = app.instancesInRailOrder().first(where: { !$0.isArchived })?.screenName |
| 91 | ?? app.instances.first?.screenName ?? "" |
| 92 | let inst = try await SpaceClaim.claim(setupLink: trimmed, screenName: screenName) |
| 93 | app.addInstance(inst) // Keychain + rail + select + vault push |
| 94 | dismiss() |
| 95 | } catch { |
| 96 | self.error = (error as? LocalizedError)?.errorDescription |
| 97 | ?? "Couldn't add that space." |
| 98 | busy = false |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |