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..4b8b6d1b0 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift @@ -0,0 +1,300 @@ +// +// 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). + /// The 2d use limits, in the spec's order (Uses: 1, 5, 10, 25, No limit). + enum UseLimit: CaseIterable, Hashable { + case one + case five + case ten + case twentyFive + case unlimited + + var title: String { + switch self { + case .one: "1" + case .five: "5" + case .ten: "10" + case .twentyFive: "25" + case .unlimited: "No limit" + } + } + + var maxUses: UInt32 { + switch self { + case .one: 1 + case .five: 5 + case .ten: 10 + case .twentyFive: 25 + case .unlimited: 0 + } + } + } + + /// 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? + /// The load in flight, which a second caller joins. + @ObservationIgnored private var loading: Task? + @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`. + /// + /// A call while a load is in flight waits for that load rather than starting a second one. + func load() async throws { + if let loading { + return try await loading.value + } + let task = Task { try await performLoad() } + loading = task + defer { loading = nil } + try await task.value + } + + private func performLoad() 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: Entry + do { + entry = try await create(expiry: .sevenDays, limit: .unlimited) + } catch is CreatedUnusable { + // Only a retry can come back non-ACTIVE; that secret is spent, so start over once. + 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/InviteLinkActionSheet.swift b/Flipcash/Core/Screens/Invite Links/InviteLinkActionSheet.swift new file mode 100644 index 000000000..e544dbc94 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinkActionSheet.swift @@ -0,0 +1,88 @@ +// +// InviteLinkActionSheet.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashUI + +/// Frame 2b (node 12886:56): what a Manage Invites row offers. Drawn like ``MuteChatSheet``, the +/// same on both platforms: the link's own readout, then Share Link and a red Revoke Link. +/// +/// Share Link is only for a link this device holds the secret for; a link made elsewhere can still +/// be revoked. +struct InviteLinkActionSheet: View { + + let entry: GroupInviteLinks.Entry + @Binding var isPresented: Bool + let onShare: () -> Void + let onRevoke: () -> Void + + var body: some View { + PartialSheet(background: .backgroundMain) { + VStack(spacing: 20) { + VStack(spacing: 4) { + header + Text(meta) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + VStack(spacing: 12) { + if entry.url != nil { + ChatActionRow( + icon: Image(systemName: "paperplane"), + title: "Share Link", + accessibilityIdentifier: "invite-link-share" + ) { + onShare() + isPresented = false + } + } + ChatActionRow( + icon: Image(systemName: "nosign"), + title: "Revoke Link", + tint: .textError, + accessibilityIdentifier: "invite-link-revoke" + ) { + onRevoke() + isPresented = false + } + } + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, bottomPadding) + } + } + + /// Mirrors ``MuteChatSheet``'s title bar. + private var header: some View { + ZStack { + Text("Invite Link") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + HStack { + Spacer() + CloseButton(style: .glass, binding: $isPresented) + } + } + } + + /// "Created Oct 27 · 0 joined · Expires Nov 3". + private var meta: String { + let detail = InviteLinkCopy.rowDetail(entry) + return InviteLinkCopy.created(entry.invite).map { "\($0) · \(detail)" } ?? detail + } + + private var bottomPadding: CGFloat { + let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + return max(0, 16 - reserved) + } +} diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift b/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift new file mode 100644 index 000000000..bd838dc6b --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinkCopy.swift @@ -0,0 +1,66 @@ +// +// 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 creating = "Creating link…" + 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..4193ae742 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinkDialogs.swift @@ -0,0 +1,72 @@ +// +// 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. Please try again") + } + } + + /// 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() + } + } +} + +// MARK: - Previews - + +#Preview("1e · Invite links not available") { + let item = InviteLinkDialogs.createFailed(ErrorCreateInvite.denied, onManage: {}, onShareGroupLink: {}) + Background(color: .backgroundMain) { + VStack { + Spacer() + Dialog( + style: item.style, + title: item.title, + subtitle: item.subtitle, + dismiss: {}, + actions: item.actions + ) + .background(item.style.backgroundColor) + } + } +} diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinkPanel.swift b/Flipcash/Core/Screens/Invite Links/InviteLinkPanel.swift new file mode 100644 index 000000000..b2d5637aa --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinkPanel.swift @@ -0,0 +1,88 @@ +// +// InviteLinkPanel.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The invite share sheet's link block (surface 1, pill from frames 1a and 1b, nodes 12844:60 and +/// 12844:110): the current link with its middle elided, or "Creating link…" while the tap that +/// opened the sheet makes it, and one line describing the link once it exists. +/// +/// Plain state in, so each state renders in a preview without a session. +struct InviteLinkPanel: View { + + enum Pill: Equatable { + /// `CreateInvite`, or the load before it, is in flight. + case creating + case link(URL) + } + + let pill: Pill + /// "Expires Nov 3 · 0 joined", from the link itself. Nil until the link exists. + let meta: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + pillView + if let meta { + Text(meta) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .accessibilityIdentifier("invite-link-meta") + } + } + } + + private var pillView: some View { + HStack(spacing: 10) { + switch pill { + case .creating: + ProgressView() + .tint(Color.textMain) + .frame(width: 18, height: 18) + Text(InviteLinkCopy.creating) + .foregroundStyle(Color.textSecondary) + 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(.default(size: 14, weight: .medium)) + .lineLimit(1) + .padding(.horizontal, 16) + .frame(height: 48) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityIdentifier("invite-link-pill") + } + + /// 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...]) + } +} + +// MARK: - Previews - + +private let previewURL = URL(string: "https://app.flipcash.com/chat/invite#s=kQ3vR8mZp4LwN2yTb7cXe1aFhJ6sDg0uVq5oIrKxT9a")! + +#Preview("Invite link, current") { + Background(color: .backgroundMain) { + InviteLinkPanel(pill: .link(previewURL), meta: "Expires Nov 3 · 0 joined").padding(20) + } +} + +#Preview("Invite link, creating") { + Background(color: .backgroundMain) { + InviteLinkPanel(pill: .creating, meta: nil).padding(20) + } +} diff --git a/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift b/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift new file mode 100644 index 000000000..b54a619f4 --- /dev/null +++ b/Flipcash/Core/Screens/Invite Links/InviteLinksScreen.swift @@ -0,0 +1,440 @@ +// +// InviteLinksScreen.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit +import FlipcashUI +import FlipcashCore + +/// Manage Invites (frame 2a, node 12844:200): 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 pendingAction: RowAction? + @State private var sharing: SharedLink? + @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) + } + } + // Frame 2b. Its choice runs once the sheet is gone, so the share sheet or the revoke + // confirmation doesn't present over a sheet on its way out. + .sheet(item: $selected, onDismiss: runPendingAction) { entry in + InviteLinkActionSheet( + entry: entry, + isPresented: isSelecting, + onShare: { pendingAction = .share(entry.id) }, + onRevoke: { pendingAction = .revoke(entry) } + ) + } + // Share Link opens the invite share sheet (1f) on that row's link. + .fullScreenCover(item: $sharing) { shared in + if let links { + ShareToChatsSheet( + subject: .inviteLink(conversationID, links, entry: shared.id), + isPresented: Binding { sharing != nil } set: { if !$0 { sharing = nil } }, + onInvited: { _ in } + ) + } + } + .sheet(isPresented: $isMakingLink) { + if let links { + NewInviteLinkSheet(isPresented: $isMakingLink, isCreating: links.isCreating) { expiry, limit in + await create(expiry: expiry, limit: limit) + } + } + } + .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 enum RowAction { + case share(InviteID) + case revoke(GroupInviteLinks.Entry) + } + + private struct SharedLink: Identifiable { + let id: InviteID + } + + private func runPendingAction() { + guard let action = pendingAction else { return } + pendingAction = nil + switch action { + case .share(let id): + sharing = SharedLink(id: id) + case .revoke(let entry): + dialogItem = InviteLinkDialogs.revoke { Task { await revoke(entry) } } + } + } + + 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 { + if active.isEmpty && inactive.isEmpty { + empty + } else { + VStack(spacing: 0) { + list + Button("New Link", action: onNewLink) + .buttonStyle(.filled) + .padding(.horizontal, InviteLinksMetrics.inset) + .padding(.bottom, 16) + .accessibilityIdentifier("invite-links-new") + } + } + } + + /// Frame 2e (node 12844:395): a capsule New Link under the message, no bottom button. + private var empty: some View { + VStack(spacing: 0) { + Text("No invite links") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Text("Make a link to let people join without waiting for approval.") + .font(.default(size: 14, weight: .medium)) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 260) + .padding(.top, 8) + Button(action: onNewLink) { + Text("New Link") + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(InviteLinksMetrics.panel, in: Capsule()) + } + .buttonStyle(.plain) + .padding(.top, 24) + .accessibilityIdentifier("invite-links-new") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var list: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + if !active.isEmpty { + section("ACTIVE", entries: active, isActive: true) + } + if !inactive.isEmpty { + section("INACTIVE", entries: inactive, isActive: false) + } + } + .padding(.horizontal, InviteLinksMetrics.inset) + .padding(.vertical, 12) + } + } + + /// A section header over a rounded card of rows, as frame 2a draws them. + private func section(_ title: String, entries: [GroupInviteLinks.Entry], isActive: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.appTextHeading) + .foregroundStyle(Color.textSecondary) + .padding(.horizontal, 16) + VStack(spacing: 0) { + ForEach(entries) { entry in + let row = InviteLinkRow(entry: entry, isCurrent: entry.id == currentID, isActive: isActive) + if isActive { + Button { onSelect(entry) } label: { row } + .buttonStyle(.plain) + } else { + row + } + if entry.id != entries.last?.id { + Rectangle() + .fill(Color.rowSeparator) + .frame(height: 1) + .padding(.leading, 16) + } + } + } + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } + } +} + +private enum InviteLinksMetrics { + /// Frame 2a's card inset from the screen edge. + static let inset: CGFloat = 22 + /// The chip and capsule fill (card/panel, white 8%). + static let panel = Color.white.opacity(0.08) +} + +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: 8) { + VStack(alignment: .leading, spacing: 6) { + Text(InviteLinkCopy.created(entry.invite) ?? "") + .font(.appTextMedium) + .foregroundStyle(isActive ? Color.textMain : Color.textMain.opacity(0.5)) + Text(InviteLinkCopy.rowDetail(entry)) + .font(.appTextCaption) + .foregroundStyle(isActive ? Color.textSecondary : Color.textMain.opacity(0.5)) + } + .lineLimit(1) + Spacer(minLength: 0) + if isCurrent { + Text("Current") + .font(.appTextHeading) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(InviteLinksMetrics.panel, in: Capsule()) + } + if isActive { + Image(systemName: "chevron.right") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + .padding(.horizontal, 16) + .frame(height: 64) + .contentShape(Rectangle()) + } +} + +// MARK: - New link - + +/// Frame 2d (node 12844:334): expiry and use-limit presets, then Create Link. +struct NewInviteLinkSheet: View { + + @Binding var isPresented: Bool + let isCreating: Bool + /// Makes the link; returns whether the sheet may close. + let onCreate: (GroupInviteLinks.Expiry, GroupInviteLinks.UseLimit) async -> Bool + + @State private var expiry: GroupInviteLinks.Expiry = .sevenDays + @State private var limit: GroupInviteLinks.UseLimit = .unlimited + @State private var isSubmitting = false + + /// Presented like ``InviteLinkActionSheet`` (2b): the shared partial sheet and its title bar. + var body: some View { + PartialSheet(background: .backgroundMain) { + form + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, bottomPadding) + } + } + + private var form: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.bottom, 24) + + chips("Expires", options: GroupInviteLinks.Expiry.allCases, selection: $expiry, title: \.title) + .padding(.bottom, 24) + chips("Who can join", options: GroupInviteLinks.UseLimit.allCases, selection: $limit, title: \.title) + .padding(.bottom, 28) + + Button { + Task { + isSubmitting = true + defer { isSubmitting = false } + if await onCreate(expiry, limit) { isPresented = false } + } + } label: { + ButtonStateLabel("Create Link", state: isSubmitting || isCreating ? .loading : .normal) + } + .buttonStyle(.filled) + .disabled(isSubmitting || isCreating) + .accessibilityIdentifier("new-invite-link-create") + } + } + + private var header: some View { + ZStack { + Text("New Invite Link") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + HStack { + Spacer() + CloseButton(style: .glass, binding: $isPresented) + } + } + .frame(maxWidth: .infinity) + } + + private var bottomPadding: CGFloat { + let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + return max(0, 16 - reserved) + } + + private func chips(_ heading: String, options: [Option], selection: Binding