| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | |
| 4 | // 🤖 The iOS receiver's confirm card (web→app handoff — the Mac |
| 5 | // AddFromLinkSheet's sibling): an invite link that arrived from OUTSIDE the |
| 6 | // app (the join page's "Open in the app" scheme, or a universal link on an |
| 7 | // instance subdomain) pre-fills this sheet — it never auto-joins; the Add |
| 8 | // tap is the confirming act for any URL that came from elsewhere. |
| 9 | // Deliberately NO editable field: iOS has no paste-a-link (Paul's call — |
| 10 | // joining lives on the web), so only an externally-arrived, already-validated |
| 11 | // link can reach this card. SpaceClaim handles all three link shapes |
| 12 | // (member/setup invites, DM invites, add-device links) with no webview; the |
| 13 | // secret in the #fragment never reaches any server. |
| 14 | struct AddFromLinkConfirm: View { |
| 15 | @EnvironmentObject var app: AppState |
| 16 | @Environment(\.dismiss) private var dismiss |
| 17 | let link: String |
| 18 | /// Called after the claim persisted (app.add ran) — RootView selects the |
| 19 | /// new space and drops the pending link. |
| 20 | let onAdded: (SavedInstance) -> Void |
| 21 | @State private var busy = false |
| 22 | @State private var error: String? |
| 23 | |
| 24 | private var host: String { |
| 25 | parseJoinLink(link).flatMap { URL(string: $0.origin)?.host } ?? "" |
| 26 | } |
| 27 | |
| 28 | var body: some View { |
| 29 | VStack(spacing: 14) { |
| 30 | Image(systemName: "person.crop.circle.badge.plus") |
| 31 | .font(.system(size: 44)) |
| 32 | .foregroundStyle(.tint) |
| 33 | .accessibilityHidden(true) |
| 34 | Text("Add this space?") |
| 35 | .font(.title3.bold()) |
| 36 | Text("You opened an invite for \(host). It joins this \(DeviceName.current) and syncs to your Eleven account.") |
| 37 | .font(.callout) |
| 38 | .foregroundStyle(.secondary) |
| 39 | .multilineTextAlignment(.center) |
| 40 | if let error { |
| 41 | Text(error) |
| 42 | .font(.footnote) |
| 43 | .foregroundStyle(.red) |
| 44 | .multilineTextAlignment(.center) |
| 45 | } |
| 46 | Button(action: add) { |
| 47 | HStack(spacing: 6) { |
| 48 | if busy { ProgressView().controlSize(.small) } |
| 49 | Text(busy ? "Joining…" : "Add Space") |
| 50 | } |
| 51 | .frame(maxWidth: .infinity) |
| 52 | } |
| 53 | .buttonStyle(.borderedProminent) |
| 54 | .controlSize(.large) |
| 55 | .disabled(busy) |
| 56 | Button("Cancel") { dismiss() } |
| 57 | .disabled(busy) |
| 58 | } |
| 59 | .padding(24) |
| 60 | .presentationDetents([.medium]) |
| 61 | } |
| 62 | |
| 63 | private func add() { |
| 64 | guard !busy else { return } |
| 65 | busy = true |
| 66 | error = nil |
| 67 | Task { @MainActor in |
| 68 | do { |
| 69 | // Carry the name you already go by; an add-device link's own |
| 70 | // "~name" (or "Me") wins inside the claim when this is empty. |
| 71 | let screenName = app.instances.first(where: { !$0.isArchived })?.screenName |
| 72 | ?? app.instances.first?.screenName ?? "" |
| 73 | let inst = try await SpaceClaim.claim(setupLink: link, screenName: screenName) |
| 74 | app.add(inst) // Keychain + list + vault push |
| 75 | onAdded(inst) // RootView: select + dismiss the pending link |
| 76 | } catch { |
| 77 | self.error = (error as? LocalizedError)?.errorDescription |
| 78 | ?? "Couldn't add that space." |
| 79 | busy = false |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | |