| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | |
| 4 | // 🤖 Invite a brand-new person straight into a DM (the web's inviteSomeone, |
| 5 | // natively): type their name, get a join link to share. The link's #fragment |
| 6 | // carries the one-time secret — it never touches the server, so share it |
| 7 | // over any channel you trust. |
| 8 | struct InviteSomeoneView: View { |
| 9 | @ObservedObject var store: ThreadStore |
| 10 | let onCreated: (String?) -> Void |
| 11 | |
| 12 | @State private var name = "" |
| 13 | @State private var busy = false |
| 14 | @State private var error: String? |
| 15 | @State private var link: String? |
| 16 | @State private var copied = false |
| 17 | |
| 18 | var body: some View { |
| 19 | Group { |
| 20 | if let link { |
| 21 | ready(link: link) |
| 22 | } else { |
| 23 | form |
| 24 | } |
| 25 | } |
| 26 | .navigationTitle("Invite someone") |
| 27 | .navigationBarTitleDisplayMode(.inline) |
| 28 | } |
| 29 | |
| 30 | // Step 1: their name + a PROMINENT create button (a bare button row in a |
| 31 | // List reads as another text field — the lesson from the first device run). |
| 32 | private var form: some View { |
| 33 | VStack(spacing: 16) { |
| 34 | Form { |
| 35 | Section { |
| 36 | TextField("Their name", text: $name) |
| 37 | .textInputAutocapitalization(.words) |
| 38 | .onSubmit(create) |
| 39 | } footer: { |
| 40 | Text("Creates a pending chat with them and a join link only they can use.") |
| 41 | } |
| 42 | if let error { |
| 43 | Section { Text(error).font(.footnote).foregroundStyle(.red) } |
| 44 | } |
| 45 | } |
| 46 | Button { |
| 47 | create() |
| 48 | } label: { |
| 49 | HStack(spacing: 8) { |
| 50 | if busy { ProgressView().tint(.white) } |
| 51 | Text(busy ? "Creating…" : "Create invite") |
| 52 | .font(.body.weight(.semibold)) |
| 53 | } |
| 54 | .frame(maxWidth: .infinity) |
| 55 | .padding(.vertical, 4) |
| 56 | } |
| 57 | .buttonStyle(.borderedProminent) |
| 58 | .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) |
| 59 | .padding(.horizontal, 20) |
| 60 | .padding(.bottom, 16) |
| 61 | } |
| 62 | .background(Color(.systemGroupedBackground)) |
| 63 | } |
| 64 | |
| 65 | // Step 2: the link itself, Copy, and the system share sheet. The content |
| 66 | // scrolls (large Dynamic Type / small screens); the buttons pin below it. |
| 67 | private func ready(link: String) -> some View { |
| 68 | ScrollView { |
| 69 | VStack(spacing: 18) { |
| 70 | Image(systemName: "person.badge.plus") |
| 71 | .font(.system(size: 40)) |
| 72 | .foregroundStyle(Color.accentColor) |
| 73 | .padding(.top, 24) |
| 74 | Text("Invite ready") |
| 75 | .font(.title3.weight(.semibold)) |
| 76 | 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.") |
| 77 | .font(.callout) |
| 78 | .foregroundStyle(.secondary) |
| 79 | .multilineTextAlignment(.center) |
| 80 | .padding(.horizontal, 24) |
| 81 | Text(link) |
| 82 | .font(.footnote.monospaced()) |
| 83 | .lineLimit(3) |
| 84 | .truncationMode(.middle) |
| 85 | .textSelection(.enabled) |
| 86 | .padding(12) |
| 87 | .frame(maxWidth: .infinity) |
| 88 | .background(Color(.secondarySystemGroupedBackground), |
| 89 | in: RoundedRectangle(cornerRadius: 10)) |
| 90 | .padding(.horizontal, 20) |
| 91 | } |
| 92 | } |
| 93 | .safeAreaInset(edge: .bottom) { |
| 94 | VStack(spacing: 10) { |
| 95 | if let url = URL(string: link) { |
| 96 | ShareLink(item: url) { |
| 97 | Label("Share invite link", systemImage: "square.and.arrow.up") |
| 98 | .font(.body.weight(.semibold)) |
| 99 | .frame(maxWidth: .infinity) |
| 100 | .padding(.vertical, 4) |
| 101 | } |
| 102 | .buttonStyle(.borderedProminent) |
| 103 | } |
| 104 | Button { |
| 105 | UIPasteboard.general.string = link |
| 106 | copied = true |
| 107 | } label: { |
| 108 | Label(copied ? "Copied" : "Copy link", |
| 109 | systemImage: copied ? "checkmark" : "doc.on.doc") |
| 110 | .frame(maxWidth: .infinity) |
| 111 | .padding(.vertical, 4) |
| 112 | } |
| 113 | .buttonStyle(.bordered) |
| 114 | } |
| 115 | .padding(.horizontal, 20) |
| 116 | .padding(.top, 10) |
| 117 | .padding(.bottom, 16) |
| 118 | .background(Color(.systemGroupedBackground)) |
| 119 | } |
| 120 | .background(Color(.systemGroupedBackground)) |
| 121 | } |
| 122 | |
| 123 | private func create() { |
| 124 | let trimmed = name.trimmingCharacters(in: .whitespaces) |
| 125 | guard !trimmed.isEmpty, !busy else { return } |
| 126 | busy = true |
| 127 | error = nil |
| 128 | Task { |
| 129 | let r = await store.inviteSomeone(name: trimmed) |
| 130 | busy = false |
| 131 | if let l = r.link { |
| 132 | link = l |
| 133 | } else { |
| 134 | error = r.error ?? "Couldn't create the invite." |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | |