eleven / messenger Public

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>
Paul Campbell dc30feb89c899e0a4a130cecf7080c5ac1c58deb parent db84548
2 files changed, +238 −33
  • ios/LChat/Views/InviteSomeoneView.swift +91 −33
  • ios/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 {