From 13bf26f187e9ffd0385eb25aab8d782cd85b11ca Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 27 Oct 2026 20:53:07 -0400 Subject: [PATCH 1/7] feat(chat): invite links for hosts --- .../Navigation/AppRouter+Destination.swift | 6 +- .../AppRouter+DestinationView.swift | 4 + .../Invite Links/GroupInviteLinks.swift | 276 +++++++++++++ .../Screens/Invite Links/InviteLinkCopy.swift | 68 ++++ .../Invite Links/InviteLinkDialogs.swift | 53 +++ .../Invite Links/InviteLinkPanel.swift | 169 ++++++++ .../Invite Links/InviteLinksScreen.swift | 375 ++++++++++++++++++ .../Join Requests/JoinRequestsScreen.swift | 13 +- .../New Group/InvitePeopleViewModel.swift | 3 +- .../Tips/New Group/ShareToChatsSheet.swift | 121 +++++- .../Screens/Profile/ChatProfileScreen.swift | 6 +- .../Core/Session/SessionAuthenticator.swift | 37 ++ Flipcash/Share/GroupInviteShareItem.swift | 18 +- .../Clients/Flip API/FlipClient+Chat.swift | 2 +- .../Conversation/ConversationInvite.swift | 6 +- .../Conversation/ConversationRules.swift | 6 + .../Models/Conversation/InviteSealing.swift | 107 +++++ .../Chat/GroupInviteLinksTests.swift | 227 +++++++++++ 18 files changed, 1480 insertions(+), 17 deletions(-) create mode 100644 Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift create mode 100644 Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift create mode 100644 Flipcash/Core/Screens/Invite Links/InviteLinkDialogs.swift create mode 100644 Flipcash/Core/Screens/Invite Links/InviteLinkPanel.swift create mode 100644 Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/InviteSealing.swift create mode 100644 FlipcashTests/Chat/GroupInviteLinksTests.swift diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 107dc3b9b..76b14e9fd 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -153,6 +153,8 @@ extension AppRouter { /// The pending requests to join a private group the user hosts, pushed from its profile card, /// the Requests screen, or the chat's request anchor. case joinRequests(ConversationID) + /// A hosted private group's invite links, pushed from the Invite People sheet and the create-failed dialog. + case inviteLinks(ConversationID) /// Every member of a private group, pushed from the count on its profile's Members grid. case groupMembers(ConversationID) @@ -180,7 +182,7 @@ extension AppRouter { case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, .tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .editGroupCover, - .editGroupDescription, .archivedChats, .chatSearch, .requests, .joinRequests, .groupMembers: + .editGroupDescription, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers: return .tips } } @@ -250,6 +252,7 @@ extension AppRouter { case .chatSearch: "chatSearch" case .requests: "requests" case .joinRequests: "joinRequests" + case .inviteLinks: "inviteLinks" case .groupMembers: "groupMembers" } } @@ -285,6 +288,7 @@ extension AppRouter { .editGroupCover(let conversationID), .editGroupDescription(let conversationID), .joinRequests(let conversationID), + .inviteLinks(let conversationID), .groupMembers(let conversationID): return conversationID.description case .userProfile(let userID, _): diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index e81b5b56d..7193883db 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -247,6 +247,10 @@ struct DestinationView: View { JoinRequestsScreen(conversationID: conversationID) .id(conversationID) + case .inviteLinks(let conversationID): + InviteLinksScreen(conversationID: conversationID) + .id(conversationID) + case .groupMembers(let conversationID): GroupMembersScreen(conversationID: conversationID) .id(conversationID) diff --git a/Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift b/Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift new file mode 100644 index 000000000..50cb46d59 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift @@ -0,0 +1,276 @@ +// +// GroupInviteLinks.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Observation +import FlipcashCore + +nonisolated private let logger = Logger(label: "flipcash.group-invite-links") + +/// One private group's invite links as its host sees them: the list Manage Invites shows, the +/// current link the invite sheet shares, and making and revoking links. +/// +/// Links and secrets live only in memory. Nothing here logs a link, a secret or a token: the link +/// carries the secret in its fragment, and the secret plus the server's records open the chat key. +@MainActor +@Observable +final class GroupInviteLinks { + + /// Reads, makes and revokes invites on the server, with the crypto they need. + struct Service { + typealias List = @MainActor @Sendable (ConversationID) async throws -> [Invite] + typealias Create = @MainActor @Sendable (ConversationID, _ secret: Data, _ maxUses: UInt32, _ expiresAt: Date?) async throws -> Invite + typealias Revoke = @MainActor @Sendable (ConversationID, InviteID) async throws -> Void + typealias Secret = @MainActor @Sendable (Invite) -> Data? + typealias Link = @MainActor @Sendable (_ secret: Data) -> URL? + typealias NewSecret = @MainActor @Sendable () -> Data + + let selfUserID: UserID + let list: List + let create: Create + let revoke: Revoke + let secret: Secret + let link: Link + let newSecret: NewSecret + } + + /// An invite with the link this device can rebuild for it. + struct Entry: Identifiable, Hashable { + let invite: Invite + /// `nil` when the invite's secret envelope doesn't open on this device ("Can't share from + /// this device"), or the viewer didn't make it. + let url: URL? + + var id: InviteID { invite.id } + } + + enum LoadState: Equatable { + case idle + case loading + case loaded + case failed + } + + /// How long a new link lasts (frame 2d, Decision 11). + enum Expiry: CaseIterable, Hashable { + case oneDay + case sevenDays + case thirtyDays + case never + + var title: String { + switch self { + case .oneDay: "1 day" + case .sevenDays: "7 days" + case .thirtyDays: "30 days" + case .never: "Never" + } + } + + func expiresAt(from now: Date) -> Date? { + switch self { + case .oneDay: now.addingTimeInterval(1 * 86_400) + case .sevenDays: now.addingTimeInterval(7 * 86_400) + case .thirtyDays: now.addingTimeInterval(30 * 86_400) + case .never: nil + } + } + } + + /// How many people a new link admits (frame 2d). + enum UseLimit: CaseIterable, Hashable { + case unlimited + case one + case ten + case fifty + + var title: String { + switch self { + case .unlimited: "No limit" + case .one: "1 person" + case .ten: "10" + case .fifty: "50" + } + } + + var maxUses: UInt32 { + switch self { + case .unlimited: 0 + case .one: 1 + case .ten: 10 + case .fifty: 50 + } + } + } + + /// The server returned an invite that doesn't admit anyone, or the link couldn't be built. + struct CreatedUnusable: Error {} + + /// How long an inactive invite stays listed (Decision 11). + static let inactiveRetention: TimeInterval = 30 * 86_400 + + let conversationID: ConversationID + + /// Every invite the server lists, newest first. + private(set) var entries: [Entry] = [] + private(set) var loadState: LoadState = .idle + private(set) var isCreating = false + private(set) var revoking: Set = [] + + /// The secret of the create in flight or last failed, reused on retry so a retried + /// `CreateInvite` carries the same token, on which the server is idempotent. + @ObservationIgnored private var pendingSecret: Data? + @ObservationIgnored private let service: Service + @ObservationIgnored private let now: () -> Date + + init(conversationID: ConversationID, service: Service, now: @escaping () -> Date = { .now }) { + self.conversationID = conversationID + self.service = service + self.now = now + } + + // MARK: - Sections - + + var active: [Entry] { + entries.filter { $0.invite.status == .active } + } + + /// Exhausted, expired and revoked invites, less those more than 30 days past their end. + var inactive: [Entry] { + let cutoff = now().addingTimeInterval(-Self.inactiveRetention) + return entries.filter { entry in + guard entry.invite.status != .active else { return false } + return (Self.endDate(of: entry.invite) ?? .distantPast) >= cutoff + } + } + + /// The link the invite sheet shares: the newest active invite the viewer made whose secret + /// opens here. + var current: Entry? { + entries.first { entry in + entry.invite.status == .active + && entry.invite.createdBy == service.selfUserID + && entry.url != nil + } + } + + /// When an inactive invite stopped admitting people, as far as the client can tell: its + /// expiry for an expired one, else when it was made. The proto carries no revoke time. + static func endDate(of invite: Invite) -> Date? { + switch invite.status { + case .expired: + invite.expiresAt ?? invite.createdAt + case .unknown, .active, .exhausted, .revoked: + invite.createdAt + } + } + + // MARK: - Load - + + /// Reads every invite, newest first. Throws the server's error so a screen can tell `DENIED`. + func load() async throws { + loadState = .loading + do { + let invites = try await service.list(conversationID) + entries = invites + .sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) } + .map(entry(for:)) + loadState = .loaded + } catch { + loadState = .failed + throw error + } + } + + // MARK: - Current link - + + /// The current link, making one (7 days, no use limit) when there is no active one. A first + /// load runs here if the sheet's own load hasn't finished or failed. + func currentLink() async throws -> URL { + if loadState != .loaded { + try await load() + } + if let url = current?.url { + return url + } + let entry = try await create(expiry: .sevenDays, limit: .unlimited) + guard let url = entry.url else { throw CreatedUnusable() } + return url + } + + // MARK: - Create - + + /// Makes an invite, which becomes ``current``. Throws ``ErrorCreateInvite`` from the server. + @discardableResult + func create(expiry: Expiry, limit: UseLimit) async throws -> Entry { + isCreating = true + defer { isCreating = false } + + let secret = pendingSecret ?? service.newSecret() + pendingSecret = secret + + let invite: Invite + do { + invite = try await service.create(conversationID, secret, limit.maxUses, expiry.expiresAt(from: now())) + } catch let error as ErrorCreateInvite { + // Only a transport failure is worth retrying with the same token; any answer from the + // server is final for this secret. + if error != .transportFailure { pendingSecret = nil } + throw error + } + pendingSecret = nil + + guard invite.status == .active, let url = service.link(secret) else { + logger.warning("CreateInvite returned an unusable invite", metadata: [ + "status": "\(invite.status)", + ]) + throw CreatedUnusable() + } + + let entry = Entry(invite: invite, url: url) + entries.removeAll { $0.id == entry.id } + entries.insert(entry, at: 0) + return entry + } + + // MARK: - Revoke - + + /// Revokes an invite. The server answers OK for one already revoked, so a retry is safe. + func revoke(_ entry: Entry) async throws { + guard !revoking.contains(entry.id) else { return } + revoking.insert(entry.id) + defer { revoking.remove(entry.id) } + + try await service.revoke(conversationID, entry.id) + guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return } + let old = entries[index].invite + entries[index] = Entry( + invite: Invite( + id: old.id, + conversationID: old.conversationID, + createdBy: old.createdBy, + maxUses: old.maxUses, + useCount: old.useCount, + createdAt: old.createdAt, + expiresAt: old.expiresAt, + status: .revoked, + secretEnvelope: old.secretEnvelope + ), + url: nil + ) + } + + // MARK: - Private - + + private func entry(for invite: Invite) -> Entry { + // Only the creator's own envelope opens with this owner key. + guard invite.createdBy == service.selfUserID, let secret = service.secret(invite) else { + return Entry(invite: invite, url: nil) + } + return Entry(invite: invite, url: service.link(secret)) + } +} diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift b/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift new file mode 100644 index 000000000..9340b0209 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift @@ -0,0 +1,68 @@ +// +// InviteLinkCopy.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// The invite sheet's and Manage Invites' strings (frames 1a–1e, 2a–2e). +enum InviteLinkCopy { + + static let sheetSubtitle = "Private group · Invite link" + static let creating = "Creating link…" + static let defaultExpiry = "Expires in 7 days" + static let footnote = "Anyone with this link can join and see who’s in the group. Share it only with people you want in the group" + static let cantShare = "Can’t share from this device" + + static func sheetTitle(groupName: String) -> String { + groupName.isEmpty ? "Invite People" : "Invite to \(groupName)" + } + + /// "Created Oct 27". + static func created(_ invite: Invite) -> String? { + invite.createdAt.map { "Created \(day($0))" } + } + + /// "3 joined", or "3 of 10 joined" for a link with a use limit. + static func joined(_ invite: Invite) -> String { + invite.maxUses == 0 ? "\(invite.useCount) joined" : "\(invite.useCount) of \(invite.maxUses) joined" + } + + /// "Expires Nov 3", or "Never expires". + static func expiry(_ invite: Invite) -> String { + invite.expiresAt.map { "Expires \(day($0))" } ?? "Never expires" + } + + /// The sheet's meta line under the current link: "Expires Nov 3 · 0 joined". + static func sheetMeta(_ invite: Invite) -> String { + "\(expiry(invite)) · \(joined(invite))" + } + + /// A Manage Invites row's second line. + static func rowDetail(_ entry: GroupInviteLinks.Entry) -> String { + let invite = entry.invite + switch invite.status { + case .active: + return entry.url == nil + ? "\(joined(invite)) · \(cantShare)" + : "\(joined(invite)) · \(expiry(invite))" + case .expired: + let ended = invite.expiresAt.map { "Expired \(day($0))" } ?? "Expired" + return "\(ended) · \(joined(invite))" + case .revoked: + // The proto carries no revoke time, so the date the frame shows isn't available. + return "Revoked · \(joined(invite))" + case .exhausted: + return "Reached its limit · \(joined(invite))" + case .unknown: + return joined(invite) + } + } + + private static func day(_ date: Date) -> String { + date.formatted(.dateTime.month(.abbreviated).day()) + } +} diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinkDialogs.swift b/Flipcash/Core/Screens/Invite Links/InviteLinkDialogs.swift new file mode 100644 index 000000000..dfd84ebe3 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinkDialogs.swift @@ -0,0 +1,53 @@ +// +// InviteLinkDialogs.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The invite links' bottom bars (frames 1c, 1e, 2c). +@MainActor +enum InviteLinkDialogs { + + /// What a failed `CreateInvite` shows: 1c for too many links, 1e for DENIED, and a generic + /// failure for anything else, `INVALID_SIGNATURE` included. + static func createFailed(_ error: Error, onManage: @escaping () -> Void, onShareGroupLink: @escaping () -> Void) -> DialogItem { + switch error as? ErrorCreateInvite { + case .tooManyInvites: + // The server allows 20 active links per group (Decision 7). + .info( + title: "Too Many Active Links", + subtitle: "You have 20 active invite links. Revoke one in Manage Invites to make another" + ) { + DialogAction.standard("Manage Invites", action: onManage) + DialogAction.cancel() + } + case .denied: + // Only staff may make invites for now (Decision 10). Offers the plain link, no retry. + .info( + title: "Invite Links Aren’t Available Yet", + subtitle: "You can still share the group link. People who open it can ask to join, and you approve them" + ) { + DialogAction.standard("Share Group Link", action: onShareGroupLink) + DialogAction.cancel() + } + default: + .error(title: "Couldn’t Create Link", subtitle: "Something went wrong.") + } + } + + /// Frame 2c. Revoking removes no one. + static func revoke(onRevoke: @escaping () -> Void) -> DialogItem { + .alert( + title: "Revoke this link?", + subtitle: "No one new can join with it. People who already joined stay in the group" + ) { + DialogAction.destructive("Revoke Link", action: onRevoke) + DialogAction.cancel() + } + } +} diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinkPanel.swift b/Flipcash/Core/Screens/Invite Links/InviteLinkPanel.swift new file mode 100644 index 000000000..e90a086c6 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinkPanel.swift @@ -0,0 +1,169 @@ +// +// InviteLinkPanel.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The invite sheet's link block for a host (frames 1a, 1b, nodes 12844:60 and 12844:110): the +/// current link in a pill, its actions, the meta line and the footnote. +/// +/// Plain state in, closures out, so every frame renders in a preview without a session. +struct InviteLinkPanel: View { + + enum Pill: Equatable { + /// The invites haven't loaded yet. + case loading + /// No active link of the viewer's: the first Share or Copy makes one. + case none + /// `CreateInvite` is in flight. + case creating + case link(URL) + } + + let pill: Pill + /// "Expires Nov 3 · 0 joined" under a current link; the new link's terms otherwise. + let meta: String + let didCopy: Bool + let onShare: () -> Void + let onCopy: () -> Void + let onShareRequestLink: () -> Void + let onManage: () -> Void + /// Show Group Card, which carries the plain group link (Decision 6). Nil hides the row. + let onShowGroupCard: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(InviteLinkCopy.sheetSubtitle) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .padding(.bottom, 16) + + pillView + .padding(.bottom, 8) + + row("Share Link", systemImage: "paperplane", identifier: "invite-link-share", action: onShare) + separator + row(didCopy ? "Copied" : "Copy Invite Link", systemImage: didCopy ? "checkmark.circle" : "link", identifier: "invite-link-copy", action: onCopy) + separator + row("Share Request-to-Join Link", systemImage: "link", identifier: "invite-link-request", action: onShareRequestLink) + separator + row("Manage Invites", systemImage: "line.3.horizontal", identifier: "invite-link-manage", action: onManage) + if let onShowGroupCard { + separator + row("Show Group Card", systemImage: "qrcode", identifier: "group-card-tile", action: onShowGroupCard) + } + + Text(meta) + .font(.appTextCaption) + .foregroundStyle(Color.textMain.opacity(0.5)) + .padding(.top, 16) + Text(InviteLinkCopy.footnote) + .font(.appTextCaption) + .foregroundStyle(Color.textMain.opacity(0.5)) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + } + } + + private var pillView: some View { + HStack(spacing: 8) { + switch pill { + case .loading: + ProgressView().tint(Color.textMain) + case .creating: + ProgressView().tint(Color.textMain) + Text(InviteLinkCopy.creating) + .foregroundStyle(Color.textMain.opacity(0.5)) + case .none: + Text(Self.placeholder) + .foregroundStyle(Color.textMain.opacity(0.5)) + case .link(let url): + // Elided in the middle, so the host and the secret's tail both show. + Text(Self.display(url)) + .foregroundStyle(Color.textMain) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + .font(.appTextMedium) + .lineLimit(1) + .padding(.horizontal, 16) + .frame(height: 48) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityIdentifier("invite-link-pill") + } + + private var separator: some View { + Rectangle() + .fill(Color.rowSeparator) + .frame(height: 1) + .padding(.leading, 46) + } + + private func row(_ title: String, systemImage: String, identifier: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 20) { + Image(systemName: systemImage) + .frame(width: 26) + Text(title) + .font(.appTextMedium) + Spacer() + Image(systemName: "chevron.right") + .font(.appTextSmall) + .foregroundStyle(Color.textMain.opacity(0.5)) + } + .foregroundStyle(Color.textMain) + .frame(minHeight: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } + + /// The link without its scheme, as the pill draws it. + static func display(_ url: URL) -> String { + let string = url.absoluteString + guard let range = string.range(of: "://") else { return string } + return String(string[range.upperBound...]) + } + + /// What the pill holds before a link exists: the link's shape, with no secret. + static let placeholder = "app.flipcash.com/chat/invite" +} + +// MARK: - Previews - + +private let previewURL = URL(string: "https://app.flipcash.com/chat/invite#s=kQ3vR8mZp4LwN2yTb7cXe1aFhJ6sDg0uVq5oIrKxT9a")! + +private func panel(_ pill: InviteLinkPanel.Pill, meta: String) -> some View { + Background(color: .backgroundMain) { + InviteLinkPanel( + pill: pill, + meta: meta, + didCopy: false, + onShare: {}, + onCopy: {}, + onShareRequestLink: {}, + onManage: {}, + onShowGroupCard: {} + ) + .padding(20) + } +} + +#Preview("1a · Current link") { + panel(.link(previewURL), meta: "Expires Nov 3 · 0 joined") +} + +#Preview("1b · Creating link") { + panel(.creating, meta: InviteLinkCopy.defaultExpiry) +} + +#Preview("No link yet") { + panel(.none, meta: InviteLinkCopy.defaultExpiry) +} diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift b/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift new file mode 100644 index 000000000..887efaa7b --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift @@ -0,0 +1,375 @@ +// +// InviteLinksScreen.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit +import FlipcashUI +import FlipcashCore + +/// Manage Invites (frame 2a, node 12844:209): a hosted private group's invite links, Active then +/// Inactive, with sharing, revoking and making new ones. Pushed from the invite sheet and from the +/// profile's ⋯ menu. +struct InviteLinksScreen: View { + + let conversationID: ConversationID + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(ConversationController.self) private var conversationController + @Environment(AppRouter.self) private var router + + @State private var links: GroupInviteLinks? + @State private var selected: GroupInviteLinks.Entry? + @State private var isMakingLink = false + @State private var dialogItem: DialogItem? + + var body: some View { + Background(color: .backgroundMain) { + if let links, links.loadState == .loaded { + InviteLinksContent( + active: links.active, + inactive: links.inactive, + currentID: links.current?.id, + onSelect: { selected = $0 }, + onNewLink: { isMakingLink = true } + ) + } else { + ProgressView() + .tint(Color.textMain) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("Invite Links") + .navigationBarTitleDisplayMode(.inline) + .task(id: conversationID) { + let links = links ?? GroupInviteLinks(conversationID: conversationID, service: sessionContainer.inviteLinks) + self.links = links + do { + try await links.load() + } catch { + handle(error) + } + } + // The native action sheet of frame 2b. + .confirmationDialog("", isPresented: isSelecting, titleVisibility: .hidden, presenting: selected) { entry in + if let url = entry.url { + Button("Share Link") { share(url) } + Button("Copy Invite Link") { UIPasteboard.general.string = url.absoluteString } + } + Button("Revoke Link", role: .destructive) { + dialogItem = InviteLinkDialogs.revoke { Task { await revoke(entry) } } + } + Button("Cancel", role: .cancel) {} + } + .sheet(isPresented: $isMakingLink) { + if let links { + NewInviteLinkSheet(isCreating: links.isCreating) { expiry, limit in + await create(expiry: expiry, limit: limit) + } + .presentationDetents([.medium]) + .presentationBackground(Color.backgroundMain) + } + } + .dialog(item: $dialogItem) + } + + private var isSelecting: Binding { + Binding(get: { selected != nil }, set: { if !$0 { selected = nil } }) + } + + private var groupName: String? { + conversationController.conversation(withID: conversationID)?.title + } + + private func share(_ url: URL) { + Analytics.groupInviteShared(method: .share) + ShareSheet.present(activityItems: [GroupInviteShareItem(url: url, title: groupName, kind: .inviteLink)]) { _ in } + } + + private func revoke(_ entry: GroupInviteLinks.Entry) async { + guard let links else { return } + do { + try await links.revoke(entry) + } catch { + handle(error) + } + } + + /// Makes a link from 2d. Returns whether the sheet may close. + private func create(expiry: GroupInviteLinks.Expiry, limit: GroupInviteLinks.UseLimit) async -> Bool { + guard let links else { return false } + do { + try await links.create(expiry: expiry, limit: limit) + return true + } catch { + isMakingLink = false + dialogItem = InviteLinkDialogs.createFailed(error, onManage: {}, onShareGroupLink: shareGroupLink) + return false + } + } + + private func shareGroupLink() { + ShareSheet.present(activityItems: [GroupInviteShareItem(url: .groupChatInvite(for: conversationID), title: groupName)]) { _ in } + } + + /// DENIED on load or revoke means the viewer can no longer invite: back to the profile. + private func handle(_ error: Error) { + if (error as? ErrorGetChatInvites) == .denied || (error as? ErrorRevokeInvite) == .denied { + router.popTopmost() + return + } + dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again") + } +} + +// MARK: - Content - + +/// Frames 2a and 2e as plain state, for the screen and its previews. +struct InviteLinksContent: View { + + let active: [GroupInviteLinks.Entry] + let inactive: [GroupInviteLinks.Entry] + let currentID: InviteID? + let onSelect: (GroupInviteLinks.Entry) -> Void + let onNewLink: () -> Void + + var body: some View { + VStack(spacing: 0) { + if active.isEmpty && inactive.isEmpty { + empty + } else { + list + } + Button("New Link", action: onNewLink) + .buttonStyle(.filled) + .padding(.horizontal, 20) + .padding(.bottom, 16) + .accessibilityIdentifier("invite-links-new") + } + } + + /// Frame 2e. + private var empty: some View { + VStack(spacing: 8) { + Spacer() + Text("No invite links") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + Text("Make a link to let people join without waiting for approval.") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + Spacer() + } + .padding(.horizontal, 40) + .frame(maxWidth: .infinity) + } + + private var list: some View { + List { + if !active.isEmpty { + Section { + ForEach(active) { entry in + Button { onSelect(entry) } label: { + InviteLinkRow(entry: entry, isCurrent: entry.id == currentID, isActive: true) + } + .buttonStyle(.plain) + } + } header: { + sectionHeader("ACTIVE") + } + } + if !inactive.isEmpty { + Section { + ForEach(inactive) { entry in + InviteLinkRow(entry: entry, isCurrent: false, isActive: false) + } + } header: { + sectionHeader("INACTIVE") + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .listRowBackground(Color.clear) + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.appTextHeading) + .foregroundStyle(Color.textSecondary) + } +} + +private struct InviteLinkRow: View { + + let entry: GroupInviteLinks.Entry + let isCurrent: Bool + /// Inactive rows are dimmed and take no taps. + let isActive: Bool + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(InviteLinkCopy.created(entry.invite) ?? "") + .font(.appTextMedium) + Text(InviteLinkCopy.rowDetail(entry)) + .font(.appTextCaption) + .foregroundStyle(isActive ? Color.textSecondary : Color.textMain.opacity(0.5)) + } + Spacer() + if isCurrent { + Text("Current") + .font(.appTextCaption) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.backgroundRow, in: Capsule()) + } + } + .foregroundStyle(isActive ? Color.textMain : Color.textMain.opacity(0.5)) + .padding(.vertical, 12) + .contentShape(Rectangle()) + .listRowBackground(Color.clear) + .listRowSeparatorTint(Color.rowSeparator) + } +} + +// MARK: - New link - + +/// Frame 2d (node 12844:268): expiry and use-limit presets, then Create Link. +struct NewInviteLinkSheet: View { + + let isCreating: Bool + /// Makes the link; returns whether the sheet may close. + let onCreate: (GroupInviteLinks.Expiry, GroupInviteLinks.UseLimit) async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var expiry: GroupInviteLinks.Expiry = .sevenDays + @State private var limit: GroupInviteLinks.UseLimit = .unlimited + @State private var isSubmitting = false + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + Text("New Invite Link") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + + chips("Expires", options: GroupInviteLinks.Expiry.allCases, selection: $expiry, title: \.title) + chips("Who can join", options: GroupInviteLinks.UseLimit.allCases, selection: $limit, title: \.title) + + Spacer(minLength: 0) + + Button { + Task { + isSubmitting = true + defer { isSubmitting = false } + if await onCreate(expiry, limit) { dismiss() } + } + } label: { + ButtonStateLabel("Create Link", state: isSubmitting || isCreating ? .loading : .normal) + } + .buttonStyle(.filled) + .disabled(isSubmitting || isCreating) + .accessibilityIdentifier("new-invite-link-create") + } + .padding(20) + } + + private func chips(_ heading: String, options: [Option], selection: Binding