Mac: Invite someone in the New-chat popover (#476)
* mac: Invite someone in the New-chat popover iOS has had InviteSomeoneView in its picker; the Mac popover was missing the flow entirely. Same shape, popover-sized: a person.badge.plus row with the people, flipping to a name field -> one-time join link with Copy + Share. All minting is the existing native ThreadStore.inviteSomeone (the secret stays in the #fragment, never touching the server). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: Invite someone — a real button, then a real result screen 'Create invite' is a prominent filled button under the form (a bare button row in a Form reads as another text field — device feedback). The result is its own screen: the link in a card, Share invite link (the system share sheet) as the primary action, Copy link beneath. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac+ios: invite review fixes — stale-state guards, scrollable ready screen Mac popover: Back disables mid-mint, an in-flight completion only applies when its generation matches (no stale link/empty-name leak), and a closed popover forgets the invite pane. iOS: the ready screen scrolls with the Share/Copy buttons pinned in a safe-area inset, so large Dynamic Type and small screens never push them off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+238
−33
ios/LChat/Views/InviteSomeoneView.swift+91 −33ios/LChatMac/NewChatPopover.swift+147 −0
diff --git a/ios/LChat/Views/InviteSomeoneView.swift b/ios/LChat/Views/InviteSomeoneView.swift| index 5568ac4..cc3b17d 100644 |
| --- a/ios/LChat/Views/InviteSomeoneView.swift |
| +++ b/ios/LChat/Views/InviteSomeoneView.swift |
| @@ -16,50 +16,108 @@ struct InviteSomeoneView: View { |
| @State private var copied = false |
| var body: some View { |
| - List { |
| + Group { |
| if let link { |
| - Section { |
| - 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.") |
| - .font(.footnote) |
| - .foregroundStyle(.secondary) |
| - Text(link) |
| - .font(.footnote.monospaced()) |
| - .lineLimit(3) |
| - .textSelection(.enabled) |
| - ShareLink(item: URL(string: link)!) { |
| - Label("Share invite link", systemImage: "square.and.arrow.up") |
| - } |
| - Button { |
| - UIPasteboard.general.string = link |
| - copied = true |
| - } label: { |
| - Label(copied ? "Copied" : "Copy link", |
| - systemImage: copied ? "checkmark" : "doc.on.doc") |
| - } |
| - } header: { |
| - Text("Invite ready") |
| - } |
| + ready(link: link) |
| } else { |
| + form |
| + } |
| + } |
| + .navigationTitle("Invite someone") |
| + .navigationBarTitleDisplayMode(.inline) |
| + } |
| + |
| + // Step 1: their name + a PROMINENT create button (a bare button row in a |
| + // List reads as another text field — the lesson from the first device run). |
| + private var form: some View { |
| + VStack(spacing: 16) { |
| + Form { |
| Section { |
| TextField("Their name", text: $name) |
| .textInputAutocapitalization(.words) |
| .onSubmit(create) |
| - Button { |
| - create() |
| - } label: { |
| - if busy { ProgressView() } else { Text("Create invite") } |
| - } |
| - .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) |
| - if let error { |
| - Text(error).font(.footnote).foregroundStyle(.red) |
| - } |
| } footer: { |
| Text("Creates a pending chat with them and a join link only they can use.") |
| } |
| + if let error { |
| + Section { Text(error).font(.footnote).foregroundStyle(.red) } |
| + } |
| } |
| + Button { |
| + create() |
| + } label: { |
| + HStack(spacing: 8) { |
| + if busy { ProgressView().tint(.white) } |
| + Text(busy ? "Creating…" : "Create invite") |
| + .font(.body.weight(.semibold)) |
| + } |
| + .frame(maxWidth: .infinity) |
| + .padding(.vertical, 4) |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) |
| + .padding(.horizontal, 20) |
| + .padding(.bottom, 16) |
| } |
| - .navigationTitle("Invite someone") |
| - .navigationBarTitleDisplayMode(.inline) |
| + .background(Color(.systemGroupedBackground)) |
| + } |
| + |
| + // Step 2: the link itself, Copy, and the system share sheet. The content |
| + // scrolls (large Dynamic Type / small screens); the buttons pin below it. |
| + private func ready(link: String) -> some View { |
| + ScrollView { |
| + VStack(spacing: 18) { |
| + Image(systemName: "person.badge.plus") |
| + .font(.system(size: 40)) |
| + .foregroundStyle(Color.accentColor) |
| + .padding(.top, 24) |
| + Text("Invite ready") |
| + .font(.title3.weight(.semibold)) |
| + 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.") |
| + .font(.callout) |
| + .foregroundStyle(.secondary) |
| + .multilineTextAlignment(.center) |
| + .padding(.horizontal, 24) |
| + Text(link) |
| + .font(.footnote.monospaced()) |
| + .lineLimit(3) |
| + .truncationMode(.middle) |
| + .textSelection(.enabled) |
| + .padding(12) |
| + .frame(maxWidth: .infinity) |
| + .background(Color(.secondarySystemGroupedBackground), |
| + in: RoundedRectangle(cornerRadius: 10)) |
| + .padding(.horizontal, 20) |
| + } |
| + } |
| + .safeAreaInset(edge: .bottom) { |
| + VStack(spacing: 10) { |
| + if let url = URL(string: link) { |
| + ShareLink(item: url) { |
| + Label("Share invite link", systemImage: "square.and.arrow.up") |
| + .font(.body.weight(.semibold)) |
| + .frame(maxWidth: .infinity) |
| + .padding(.vertical, 4) |
| + } |
| + .buttonStyle(.borderedProminent) |
| + } |
| + Button { |
| + UIPasteboard.general.string = link |
| + copied = true |
| + } label: { |
| + Label(copied ? "Copied" : "Copy link", |
| + systemImage: copied ? "checkmark" : "doc.on.doc") |
| + .frame(maxWidth: .infinity) |
| + .padding(.vertical, 4) |
| + } |
| + .buttonStyle(.bordered) |
| + } |
| + .padding(.horizontal, 20) |
| + .padding(.top, 10) |
| + .padding(.bottom, 16) |
| + .background(Color(.systemGroupedBackground)) |
| + } |
| + .background(Color(.systemGroupedBackground)) |
| } |
| private func create() { |
diff --git a/ios/LChatMac/NewChatPopover.swift b/ios/LChatMac/NewChatPopover.swift| index 528d240..306e2d3 100644 |
| --- a/ios/LChatMac/NewChatPopover.swift |
| +++ b/ios/LChatMac/NewChatPopover.swift |
| @@ -15,6 +15,17 @@ struct NewChatPopover: View { |
| @State private var groupName = "" |
| @State private var busy = false |
| @State private var error: String? |
| + // Invite-someone mode (the iOS InviteSomeoneView, popover-sized): type a |
| + // name, get a one-time join link to share. The #fragment carries the |
| + // secret — it never touches the server. |
| + @State private var inviteMode = false |
| + @State private var inviteName = "" |
| + @State private var inviteLink: String? |
| + @State private var copied = false |
| + // Bumped by Back/close: an in-flight createInvite completion only applies |
| + // when its generation still matches, so a stale result can't leak into a |
| + // reset (or re-opened) pane. |
| + @State private var inviteGen = 0 |
| @Environment(\.colorScheme) private var scheme |
| private var accent: Color { ChatTheme.classic.accent.resolved(scheme) } |
| @@ -23,6 +34,19 @@ struct NewChatPopover: View { |
| } |
| var body: some View { |
| + Group { |
| + if inviteMode { |
| + invitePane |
| + } else { |
| + pickerPane |
| + } |
| + } |
| + // A closed popover forgets the invite pane — reopening always starts |
| + // at the picker, never a stale link (or someone else's name). |
| + .onDisappear { resetInvitePane() } |
| + } |
| + |
| + private var pickerPane: some View { |
| VStack(alignment: .leading, spacing: 8) { |
| HStack { |
| Text(groupMode ? "New group" : "New chat") |
| @@ -46,6 +70,11 @@ struct NewChatPopover: View { |
| ForEach(others, id: \.user_id) { person in |
| personRow(person) |
| } |
| + // Bring someone new onto the server (the iOS picker's row; |
| + // the server says no if the admin turned member invites off). |
| + if !groupMode { |
| + inviteSomeoneRow |
| + } |
| // Personal apps, like the web's New-chat APPS section: |
| // click to open (created on first use). Not group members, |
| // so they hide in group mode. |
| @@ -86,6 +115,124 @@ struct NewChatPopover: View { |
| .tint(accent) |
| } |
| + // The row that flips to invite mode — sits with the people, like iOS. |
| + private var inviteSomeoneRow: some View { |
| + Button { |
| + inviteMode = true |
| + } label: { |
| + HStack(spacing: 8) { |
| + Image(systemName: "person.badge.plus") |
| + .font(.system(size: 14)) |
| + .foregroundStyle(.tint) |
| + .frame(width: 26, height: 26) |
| + Text("Invite someone") |
| + .font(.system(size: 13, weight: .medium)) |
| + Spacer() |
| + } |
| + .padding(.horizontal, 8) |
| + .padding(.vertical, 6) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .contentShape(Rectangle()) |
| + } |
| + .buttonStyle(.plain) |
| + .disabled(busy) |
| + } |
| + |
| + private var invitePane: some View { |
| + VStack(alignment: .leading, spacing: 8) { |
| + HStack { |
| + Text("Invite someone") |
| + .font(.system(size: 13, weight: .semibold)) |
| + Spacer() |
| + Button("Back") { resetInvitePane() } |
| + .buttonStyle(.link) |
| + .font(.system(size: 12)) |
| + .disabled(busy) // mid-mint, Back would strand a live invite |
| + } |
| + Divider() |
| + if let link = inviteLink { |
| + Text("Send this link to \(inviteName.trimmingCharacters(in: .whitespaces)) — it's their one-time key into your chat. A new one can be minted later if it's lost.") |
| + .font(.system(size: 12)) |
| + .foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + Text(link) |
| + .font(.system(size: 11).monospaced()) |
| + .lineLimit(3) |
| + .truncationMode(.middle) |
| + .textSelection(.enabled) |
| + HStack(spacing: 8) { |
| + Button { |
| + NSPasteboard.general.clearContents() |
| + NSPasteboard.general.setString(link, forType: .string) |
| + copied = true |
| + } label: { |
| + Label(copied ? "Copied" : "Copy Link", |
| + systemImage: copied ? "checkmark" : "doc.on.doc") |
| + } |
| + if let url = URL(string: link) { |
| + ShareLink(item: url) { |
| + Label("Share…", systemImage: "square.and.arrow.up") |
| + } |
| + } |
| + } |
| + } else { |
| + Text("Creates a pending chat with them and a join link only they can use.") |
| + .font(.system(size: 12)) |
| + .foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + TextField("Their name", text: $inviteName) |
| + .textFieldStyle(.roundedBorder) |
| + .font(.system(size: 13)) |
| + .onSubmit(createInvite) |
| + Button { |
| + createInvite() |
| + } label: { |
| + if busy { |
| + ProgressView().controlSize(.small).frame(maxWidth: .infinity) |
| + } else { |
| + Text("Create invite").frame(maxWidth: .infinity) |
| + } |
| + } |
| + .buttonStyle(.borderedProminent) |
| + .disabled(busy || inviteName.trimmingCharacters(in: .whitespaces).isEmpty) |
| + } |
| + if let error { |
| + Text(error).font(.system(size: 12)).foregroundStyle(.red) |
| + } |
| + } |
| + .padding(12) |
| + .frame(width: 260) |
| + .tint(accent) |
| + } |
| + |
| + private func resetInvitePane() { |
| + inviteGen += 1 // orphan any in-flight mint's completion |
| + inviteMode = false |
| + inviteLink = nil |
| + inviteName = "" |
| + copied = false |
| + error = nil |
| + busy = false |
| + } |
| + |
| + private func createInvite() { |
| + let trimmed = inviteName.trimmingCharacters(in: .whitespaces) |
| + guard !trimmed.isEmpty, !busy else { return } |
| + busy = true |
| + error = nil |
| + let gen = inviteGen |
| + Task { |
| + let r = await store.inviteSomeone(name: trimmed) |
| + guard gen == inviteGen else { return } // pane was reset meanwhile |
| + busy = false |
| + if let l = r.link { |
| + inviteLink = l |
| + } else { |
| + error = r.error ?? "Couldn't create the invite." |
| + } |
| + } |
| + } |
| + |
| private func personRow(_ person: RosterEntry) -> some View { |
| Button { |
| if groupMode { |