diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 87158330f..6358557d2 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -42,15 +42,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Where a link card looks its link up — see ``LinkCardFeed``. Container-scoped, so it outlives /// both this view and the rows that subscribe to it. let linkCardSource: any LinkCardSource - /// Fired when the user taps the profile card's call to action. The owner opens the - /// counterpart's contact card (or the add-contact sheet), same as tapping the nav title. - let onContactAction: () -> Void - /// Fired when the user taps the transcript's head card — the counterpart's in a tip DM, the - /// chat's own in a group; nil disables the card tap. - let onProfileTap: (() -> Void)? - /// Fired when the user taps the group head card's "Invite People". The card draws the - /// button only when it both asks for it and this is set. - let onGroupInvite: (() -> Void)? /// Fired when the user taps the "Encrypted" marker above a DM's first encrypted message. let onEncryptionMarkerTap: () -> Void /// Fired when the user taps an author's face in a group's gutter, with that author's user id. @@ -166,9 +157,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.pendingMediaProgress = { [conversationController] id in conversationController.pendingMediaProgress(forMessageID: id) } - screen.onContactAction = onContactAction - screen.onProfileTap = onProfileTap - screen.onGroupInvite = onGroupInvite screen.onEncryptionMarkerTap = onEncryptionMarkerTap screen.onAuthorTap = onAuthorTap screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) @@ -215,9 +203,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onMediaTap = onMediaTap context.coordinator.mintMediaURL = mintMediaURL context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt - screen.onContactAction = onContactAction - screen.onProfileTap = onProfileTap - screen.onGroupInvite = onGroupInvite screen.onEncryptionMarkerTap = onEncryptionMarkerTap screen.onAuthorTap = onAuthorTap screen.onMessageAction = keyboardFollowing(onMessageAction, screen: screen) diff --git a/Flipcash/Core/Screens/Conversation/ConversationGate.swift b/Flipcash/Core/Screens/Conversation/ConversationGate.swift index 6d98c34dd..e7936ca31 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -354,13 +354,12 @@ func conversationGatePresentation(_ gate: ConversationGate, isMember: Bool) -> C } } -/// The chat's entry rule as its head card states it (node 10125:19164), or nil when it states none. +/// The chat's entry rule as the group invite link card states it (node 10125:19164), or nil when it states none. /// /// States the rule whether or not the viewer satisfies it. The line is broken after the label /// rather than wherever the card's width falls, as the design breaks it. A dollar-token rule is /// already fully stated by its dollar amount, so naming the token too says the same thing twice; /// any other token genuinely needs naming, because the same $100 is a different quantity of each. -/// Shared by the head card and the group invite link card so the two word a rule identically. func groupRequirementLine(_ headline: ConversationGateRequirement?, mintName: String?) -> String? { switch headline { case .minimumBalance(let amount, let mint): diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 16d7a438b..9c10855ea 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -20,11 +20,6 @@ final class ConversationLoadCoordinator { /// The rendered transcript, produced off the main thread and landed here as immutable state. private(set) var items: [ChatItem] = [] - /// Whether ``items`` is the whole locally-known history, and so carries the transcript's head - /// card. Landed with `items` rather than read from the loader, so a view that draws its own - /// head card (a group's) turns it on and off in step with the rows it sits above. - private(set) var headsHistory = false - /// The people the landed transcript attributes its rows to, in the order the window first shows /// them, then the group's typists the rows leave out. The view fetches their avatars from this: the mapped rows carry only a BlurHash — /// thumbnail bytes must not ride in ``Inputs``, which is compared on every observation tick, nor @@ -43,10 +38,6 @@ final class ConversationLoadCoordinator { private let controller: ConversationController private let session: Session - /// Supplies the counterpart's profile card, resolved live — it runs inside the observation - /// scope, so whatever it reads (the contact directory, the conversation) re-triggers mapping. - /// Nil for a chat with no counterpart to card. - private let profileCard: @MainActor () -> ChatProfileCard? /// Names senders the chat's own roster leaves out, from what the device already knows about /// them. Observed like every other input, so a reload re-attributes the window in place. private let knownAuthors: KnownAuthorDirectory @@ -86,14 +77,12 @@ final class ConversationLoadCoordinator { conversationID: ConversationID, controller: ConversationController, session: Session, - knownAuthors: KnownAuthorDirectory, - profileCard: @escaping @MainActor () -> ChatProfileCard? + knownAuthors: KnownAuthorDirectory ) { self.conversationID = conversationID self.controller = controller self.session = session self.knownAuthors = knownAuthors - self.profileCard = profileCard self.loader = MessageLoader(conversationID: conversationID, controller: controller) let boundary = controller.unreadBoundary(for: conversationID) self.openingUnreadBoundary = boundary @@ -168,7 +157,6 @@ final class ConversationLoadCoordinator { let mapped = await Task.detached { Self.map(inputs, authors: authors) }.value guard let self, !Task.isCancelled else { return } self.items = mapped - self.headsHistory = inputs.headsHistory self.attributedMembers = attribution.members self.unattributedSenders = attribution.unnamed } @@ -182,7 +170,6 @@ final class ConversationLoadCoordinator { attributedMembers = attribution.members unattributedSenders = attribution.unnamed items = Self.map(inputs, authors: Self.authors(from: attribution.members)) - headsHistory = inputs.headsHistory scheduleWindowExpiry(for: inputs) } @@ -236,8 +223,8 @@ final class ConversationLoadCoordinator { // record hasn't landed yet has no gate to read, so it keeps the member menu. let access = conversation.map { Self.access(isMember: controller.isMember(of: $0), gate: controller.gateConversation($0)) } let counterpartName = conversation?.counterpart(excluding: controller.selfUserID)?.displayName ?? "" - // The head card belongs only above a short transcript — a long or paged history drops it, - // and the nav title opens the same place it would. + // Whether the window reaches the start of the chat, which decides where the encryption + // marker can sit. let headsHistory = loader.isEntireHistory(windowCount: window.count) // The window first — a reply to a nearby message resolves with no database read at all — // then the table, for a reply pointing above the window. Nothing pages the server: a quote @@ -266,7 +253,6 @@ final class ConversationLoadCoordinator { // Capped here rather than in `map`, so a typist the row will never draw is neither // compared on every tick nor sent off to have their picture fetched. typists: Array(controller.typists(in: conversationID).suffix(ChatItem.maxTypingAvatars)), - profileCard: headsHistory ? profileCard() : nil, branding: branding, conversation: conversation, // A chat whose record hasn't landed yet has no gate to read, so it keeps the member menu. @@ -355,9 +341,6 @@ final class ConversationLoadCoordinator { : [] items.append(.typingIndicator(typists: typists)) } - if let card = inputs.profileCard { - items.insert(.profileCard(card), at: 0) - } return items } @@ -425,7 +408,6 @@ final class ConversationLoadCoordinator { var suppressReceiptFor: String? /// The other members typing, oldest first, capped at ``ChatItem/maxTypingAvatars``. var typists: [UserID] - var profileCard: ChatProfileCard? var branding: [PublicKey: Branding] var conversation: Conversation? /// False for a non-member reading a group they have not joined, who is offered no Reply. @@ -449,7 +431,7 @@ final class ConversationLoadCoordinator { /// Identities for senders the chat's own roster leaves out. Compared by identity — see /// ``KnownAuthorDirectory/Snapshot``. var knownAuthors: KnownAuthorDirectory.Snapshot - /// Whether the window is the whole locally-known history — see ``headsHistory``. + /// Whether the window is the whole locally-known history, so nothing older sits above it. var headsHistory: Bool /// Where the divider goes; `.none` draws none. var unreadBoundary: UnreadBoundary diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 9fd82aea3..f971ec62d 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -71,11 +71,6 @@ struct ConversationScreen: View { /// Whether a `JoinChat` is in flight, so the gate panel's button stops taking taps. The join has /// no other UI state — it either seats membership, which re-resolves the gate, or it alerts. @State private var isJoiningChat = false - /// Memoizes the head-carded transcript — see ``TranscriptHead``. - @State private var transcriptHead = TranscriptHead() - /// Whether the invite sheet is up. A link is the only way into a group, so a member's head card - /// hands one out. - @State private var isInviting = false /// Whether the encryption explainer opened from the transcript's marker is showing. @State private var isShowingEncryptionInfo = false @State private var messageReport: MessageReportRequest? @@ -220,9 +215,7 @@ struct ConversationScreen: View { return conversationController.conversation(withID: conversationID) == nil } - /// The rule verdicts behind ``gate``, kept separately because the head card states the chat's - /// advertised listener rule (``ConversationGate/headline``) whether or not the viewer is short - /// of it, and the presentation only ever carries something unmet. + /// The rule verdicts behind ``gate``. private var gateVerdicts: ConversationGate { guard let conversationID, let conversation = conversationController.conversation(withID: conversationID) @@ -302,45 +295,6 @@ struct ConversationScreen: View { /// The transcript's rows, with the group's own card in front of them. /// - /// The card is assembled here rather than in the mapper for the reason ``authorAvatars`` is: - /// its picture bytes would otherwise ride in `ConversationLoadCoordinator.Inputs`, which is - /// byte-compared on every observation tick, and into the shared app-group container the mapped - /// rows are cached to in the clear. Reading the avatar store from `body` is also what makes a - /// landing picture redraw the card. - private var transcriptItems: [ChatItem] { - transcriptHead.prepending(groupCard, to: coordinator?.items ?? []) - } - - /// The group's card at the head of its transcript, or nil for a DM and once the history is too - /// long to head — the same rule the counterpart's profile card follows. - private var groupCard: ChatGroupCard? { - guard let group = groupConversation, coordinator?.headsHistory == true else { return nil } - return ChatGroupCard( - title: conversationController.displayName(for: group), - avatarID: group.id.description, - memberCount: group.rosterSummary.peopleCount, - imageData: groupAvatarSubject.flatMap { sessionContainer.profileAvatars.data(for: $0) }, - blurhash: group.picture?.thumbnailBlurhash, - requirement: groupCardRequirement, - showsInvite: showsGroupInvite - ) - } - - /// Whether the head card offers the invite link: any group the viewer belongs to, however many - /// have joined. A viewer who hasn't joined has no link to hand out, so their card has no button. - private var showsGroupInvite: Bool { - guard let group = groupConversation else { return false } - return conversationController.isMember(of: group) - } - - /// The chat's entry rule as the card states it (node 10125:19164), or nil when the chat states - /// none. It states the rule whether or not the viewer satisfies it, so it reads the chat's - /// advertised listener rule rather than anything the gate found unmet. The line is broken after - /// the label rather than wherever the card's width falls, as the design breaks it. - private var groupCardRequirement: String? { - groupRequirementLine(gateVerdicts.headline, mintName: headlineMint.flatMap { mintNames[$0] }) - } - /// The mint the gate's requirement names, when it names one. A requirement with no mint applies /// across every holding, so there is no single token to buy. private var gateMint: PublicKey? { @@ -352,20 +306,9 @@ struct ConversationScreen: View { } } - /// The mint the head card's line names. Not always ``gateMint``: a member reads an `.open` - /// chat, which names nothing to satisfy, and the card still states the rule the chat runs on. - private var headlineMint: PublicKey? { - gateVerdicts.headline.flatMap(Self.mint(of:)) - } - - /// Every mint the gate's copy has to name, deduplicated — at most one per requirement, and the - /// same one in a chat that gates on a single token. + /// The mints the gate's copy has to name: the requirement's, when it names one. private var gateMints: [PublicKey] { - var mints: [PublicKey] = [] - for mint in [gateMint, headlineMint].compactMap({ $0 }) where !mints.contains(mint) { - mints.append(mint) - } - return mints + gateMint.map { [$0] } ?? [] } /// Ticker for the requirement the panel names, once its metadata lands. @@ -399,7 +342,7 @@ struct ConversationScreen: View { pagesHistory: Bool ) -> ChatScreenRepresentable { ChatScreenRepresentable( - items: transcriptItems, + items: (coordinator?.items ?? []), // Paging history for a chat the server hasn't created yet fetches // against an id it doesn't know and error-reports. onReachTop: { if pagesHistory { coordinator?.reachedTop() } }, @@ -411,9 +354,6 @@ struct ConversationScreen: View { ownProfile: ownProfile, onLinkCardTap: openLinkCard, linkCardSource: sessionContainer.linkCardFeed, - onContactAction: openContactCard, - onProfileTap: profileTapAction, - onGroupInvite: openGroupInvite, onEncryptionMarkerTap: { isShowingEncryptionInfo = true }, onAuthorTap: openAuthorProfile, onMessageAction: handleMessageAction, @@ -733,13 +673,6 @@ struct ConversationScreen: View { .sheet(isPresented: $isShowingEncryptionInfo) { E2eeLearnMoreSheet(kind: .dm, isPresented: $isShowingEncryptionInfo) } - .fullScreenCover(isPresented: $isInviting) { - if let conversationID { - ShareToChatsSheet(subject: .group(conversationID), isPresented: $isInviting) { chatID in - router.push(.tipConversation(chatID)) - } - } - } .fullScreenCover(item: $messageReport) { report in NavigationStack { ReportFlowScreen( @@ -956,7 +889,7 @@ struct ConversationScreen: View { /// The transcript's own `ChatMessage` for a row, by its stable id — what the reaction handlers /// need (pills, `canReact`), as opposed to `handleMessageAction`'s `ConversationMessage` lookup. private func chatMessage(withStableID stableID: String) -> ChatMessage? { - for item in transcriptItems { + for item in (coordinator?.items ?? []) { if case .message(let message) = item, message.id == stableID { return message } @@ -1184,15 +1117,6 @@ struct ConversationScreen: View { ) } - /// The head card's invite button. - private func openGroupInvite() { - Analytics.groupInviteSheetOpened( - source: .chat, - memberCount: groupConversation?.rosterSummary.memberCount ?? 0 - ) - isInviting = true - } - /// Moves the READ pointer past a message the transcript reports on screen. private func markSeen(_ messageID: MessageID) { guard let conversationID else { return } @@ -1373,72 +1297,11 @@ struct ConversationScreen: View { conversationID: id, controller: conversationController, session: session, - knownAuthors: sessionContainer.knownAuthors, - // `profileAvatars` is captured directly so the coordinator retains - // one small store, not the whole session container. - profileCard: { [context, contactSyncController, conversationController, session, profileAvatars = sessionContainer.profileAvatars] in - // A group cards itself — the address book has nothing to say about a chat, and - // the unknown-contact fallback below would flag one as an unknown person. - guard conversationController.conversation(withID: id)?.type != .group else { - return nil - } - return Self.profileCard( - context: context, - conversationID: id, - directory: contactSyncController.resolvedContacts.onFlipcash, - controller: conversationController, - profileAvatars: profileAvatars - ) - } + knownAuthors: sessionContainer.knownAuthors ) } } - /// The card relationship for a tip DM. A tip DM has no address-book - /// relationship to fall back on, so a counterpart who hasn't claimed a - /// handle keeps the name-only card they had before handles existed. - static func tipDMCounterpart(_ member: ConversationMember?) -> ChatProfileCard.Counterpart { - guard let username = member?.username else { return .none } - return .handle(username) - } - - /// The transcript's profile card for the counterpart, resolved live from the directory the - /// same way the nav title is: the synced contact when there is one, the profile-only tip - /// counterpart for a tip DM, otherwise the counterpart's formatted number flagged as an - /// unknown contact. - private static func profileCard( - context: ConversationContext, - conversationID: ConversationID, - directory: [ResolvedContact], - controller: ConversationController, - profileAvatars: ProfileAvatarStore - ) -> ChatProfileCard { - if let conversation = controller.conversation(withID: conversationID), - conversation.type == .tipDm { - let counterpart = conversation.counterpart(excluding: controller.selfUserID) - return ChatProfileCard( - name: controller.displayName(for: conversation), - avatarID: counterpart?.userID?.uuidString ?? conversationID.description, - imageData: profileAvatars.data(for: counterpart?.userID), - blurhash: counterpart?.profilePicture?.thumbnailBlurhash, - counterpart: Self.tipDMCounterpart(counterpart) - ) - } - if let contact = context.resolvedContact(in: directory) { - return ChatProfileCard( - name: contact.displayName, - avatarID: contact.contactId, - imageData: contact.imageData, - counterpart: .contact(phone: contact.nationalPhone) - ) - } - return ChatProfileCard( - name: controller.displayName(forConversationID: conversationID), - avatarID: conversationID.description, - imageData: nil, - counterpart: .unknown - ) - } } @@ -1537,29 +1400,3 @@ private struct ConversationTitleLabel: View { } } -/// Keeps one array for a head card sitting above an unchanged transcript. -/// -/// A DM hands the loader's own `[ChatItem]` straight through, so the transcript's `newItems != items` -/// check settles on buffer identity and costs nothing. Prepending a card builds a fresh array on every -/// body pass, which defeats that check and deep-compares every row instead — on each of the many body -/// passes a push, a pop or an opening reply strip causes. Returning the previous array whenever both -/// inputs are unchanged puts the group back on the same O(1) path. -/// -/// A reference type held in `@State`: it is read during `body` and must not invalidate the view when -/// it remembers something. -@MainActor private final class TranscriptHead { - - private var lastCard: ChatGroupCard? - private var lastTail: [ChatItem] = [] - private var composed: [ChatItem] = [] - - /// `tail` with `card` in front of it, or `tail` itself when there is no card. - func prepending(_ card: ChatGroupCard?, to tail: [ChatItem]) -> [ChatItem] { - guard let card else { return tail } - if card == lastCard, tail == lastTail { return composed } - lastCard = card - lastTail = tail - composed = [.groupCard(card)] + tail - return composed - } -} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift deleted file mode 100644 index bf1fa287f..000000000 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// ChatGroupCard.swift -// FlipcashCore -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import Foundation - -/// What the group's card at the head of its transcript shows: the chat's own picture, its title, -/// and the rule it runs on. A group's counterpart is the chat itself, so this is a sibling of -/// ``ChatProfileCard`` rather than a case of it — nothing here relates to the address book, and -/// nothing there states a requirement. -public struct ChatGroupCard: Hashable, Sendable, Codable { - - /// The chat's server-set title. - public var title: String - - /// Stable identity for the avatar (monogram colour + image cache key) — the conversation id. - public var avatarID: String - - /// "1 person" / "12 people" — the same wording the group's profile and its transcript's - /// invite-link card both use, so the head card reads identically to either. - public var memberCount: String - - /// The chat picture's thumbnail bytes; nil renders the BlurHash or the monogram. - public var imageData: Data? - - /// The chat picture's BlurHash, drawn until the bytes arrive. - public var blurhash: String? - - /// The chat's stated entry rule, already worded for display, or nil when it states none. The - /// line is shown whether or not the viewer satisfies it — the design states the requirement to - /// a member too (node 10125:19256). - public var requirement: String? - - /// Whether the card offers "Invite People" under the requirement. Set for any group the viewer is - /// a member of: a link is the only way into a group, and a member is who has one to hand out. - public var showsInvite: Bool - - public init(title: String, avatarID: String, memberCount: String = "", imageData: Data? = nil, blurhash: String? = nil, requirement: String? = nil, showsInvite: Bool = false) { - self.title = title - self.avatarID = avatarID - self.memberCount = memberCount - self.imageData = imageData - self.blurhash = blurhash - self.requirement = requirement - self.showsInvite = showsInvite - } -} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift index b0262ca51..19f719c9d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatItem.swift @@ -22,11 +22,6 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { /// In a group, `typists` holds the newest few people typing, oldest first, drawn as avatars /// ahead of the bubble; it is empty in a DM, which draws the bubble alone. case typingIndicator(typists: [ChatAuthor]) - /// The counterpart's profile card at the head of a short transcript. - case profileCard(ChatProfileCard) - /// The group's own card at the head of a short group transcript — picture, title, and the - /// rule the chat runs on. - case groupCard(ChatGroupCard) /// The "Encrypted" line above the first end-to-end-encrypted message in the transcript, or at /// its head when every message is encrypted. At most one per transcript. case encryptionMarker @@ -38,8 +33,6 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { case .unreadDivider: "unread-divider" // Independent of `typists`, so a typist joining or leaving updates the row in place. case .typingIndicator: "typing-indicator" - case .profileCard: "profile-card" - case .groupCard: "group-card" case .encryptionMarker: "encryption-marker" } } @@ -52,7 +45,7 @@ public enum ChatItem: Hashable, Sendable, Codable, Identifiable { public var messageID: String? { switch self { case .message(let message): message.messageID - case .dateSeparator, .unreadDivider, .typingIndicator, .profileCard, .groupCard, .encryptionMarker: nil + case .dateSeparator, .unreadDivider, .typingIndicator, .encryptionMarker: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift deleted file mode 100644 index ee1ea514c..000000000 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// ChatProfileCard.swift -// FlipcashCore -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import Foundation - -/// What the transcript's profile-card row shows about the counterpart: their avatar, title line, -/// and how they relate to the address book (which drives the subtitle and the call to action). -public struct ChatProfileCard: Hashable, Sendable, Codable { - /// Title line: the contact's name, or their formatted number when they're not a contact. - public var name: String - /// Stable identity for the avatar (monogram color + image cache key). - public var avatarID: String - /// The contact's address-book thumbnail; nil renders the monogram placeholder. - public var imageData: Data? - /// The counterpart's profile-picture BlurHash, shown as a blurred preview while - /// their avatar bytes load. Nil for address-book contacts. - public var blurhash: String? - public var counterpart: Counterpart - - /// What the card's subtitle line says about the counterpart, and whether it - /// offers a contact action. - public enum Counterpart: Hashable, Sendable, Codable { - /// In the address book: the subtitle is their number and the CTA views their contact card. - case contact(phone: String) - /// Not in the address book: an "Unknown Contact" subtitle and a CTA that adds them. - case unknown - /// Known by their Flipcash handle: the subtitle is `@handle` and there is no - /// contact CTA — the address book has nothing to say about them. - case handle(Username) - /// No address-book relationship to surface (e.g. a tip DM counterpart known by - /// profile only): just the avatar and name, no subtitle and no contact CTA. - case none - } - - public init(name: String, avatarID: String, imageData: Data?, blurhash: String? = nil, counterpart: Counterpart) { - self.name = name - self.avatarID = avatarID - self.imageData = imageData - self.blurhash = blurhash - self.counterpart = counterpart - } -} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift index e8e4f7448..bcec0c7c8 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift @@ -236,7 +236,7 @@ extension LinkCard { public let imageData: Data? /// The chat picture's BlurHash: the avatar's preview and the band's tint. public let blurHash: String? - /// The entry rule as the chat's own head card states it, or nil when it states none. + /// The chat's entry rule as a line of copy, or nil when it states none. public let requirement: String? public init( diff --git a/FlipcashTests/Chat/ChatCashCardSizingTests.swift b/FlipcashTests/Chat/ChatCashCardSizingTests.swift index c023b9d89..46f25c87c 100644 --- a/FlipcashTests/Chat/ChatCashCardSizingTests.swift +++ b/FlipcashTests/Chat/ChatCashCardSizingTests.swift @@ -45,12 +45,6 @@ struct ChatCashCardSizingTests { @Test("Date separator, text, and receipt-carrying rows self-size (.auto)") func otherRows_areAuto() { let items: [ChatItem] = [ - .profileCard(ChatProfileCard( - name: "Ted Livingston", - avatarID: "ted", - imageData: nil, - counterpart: .contact(phone: "519 802-3885") - )), .dateSeparator(id: "sep", text: "Today 1:00 PM"), .message(ChatMessage(id: "1", text: "hello", sender: .other)), .message(ChatMessage(id: "2", text: "sent", sender: .me, receipt: .delivered)), diff --git a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift index 1d8e0efd9..cbee9ab43 100644 --- a/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift +++ b/FlipcashTests/Chat/ConversationTypingIndicatorTests.swift @@ -54,8 +54,7 @@ struct ConversationTypingIndicatorTests { conversationID: .test(1), controller: controller, session: .mock, - knownAuthors: KnownAuthorDirectory(read: { [:] }, fetch: { _ in throw CancellationError() }, cache: { _, _ in }), - profileCard: { nil } + knownAuthors: KnownAuthorDirectory(read: { [:] }, fetch: { _ in throw CancellationError() }, cache: { _, _ in }) ) return (mock, controller, coordinator) } @@ -70,7 +69,7 @@ struct ConversationTypingIndicatorTests { switch item { case .typingIndicator(let typists): return typists - case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: + case .message, .dateSeparator, .unreadDivider, .encryptionMarker: continue } } @@ -176,7 +175,7 @@ struct ConversationTypingIndicatorTests { for item in items { switch item { case .typingIndicator(let typists): return typists - case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: continue + case .message, .dateSeparator, .unreadDivider, .encryptionMarker: continue } } return nil diff --git a/FlipcashTests/ChatProfileCardCounterpartTests.swift b/FlipcashTests/ChatProfileCardCounterpartTests.swift deleted file mode 100644 index ef8196f83..000000000 --- a/FlipcashTests/ChatProfileCardCounterpartTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// ChatProfileCardCounterpartTests.swift -// FlipcashTests -// - -import Foundation -import Testing -import FlipcashCore -@testable import Flipcash - -@MainActor -@Suite("Tip DM profile card counterpart") -struct ChatProfileCardCounterpartTests { - - @Test("A counterpart who has claimed a handle shows it") - func claimedHandle_isShown() { - let member = ConversationMember( - userID: UUID(), - displayName: "Fred Wilson", - username: Username("fred_wilson") - ) - #expect(ConversationScreen.tipDMCounterpart(member) == .handle(Username("fred_wilson")!)) - } - - @Test("A counterpart with no handle renders as it did before handles existed") - func unclaimedHandle_fallsBackToNone() { - // A handle can also go away across a profile refresh, and the card has - // to survive that rather than show an empty line. - let member = ConversationMember(userID: UUID(), displayName: "Fred Wilson") - #expect(ConversationScreen.tipDMCounterpart(member) == ChatProfileCard.Counterpart.none) - } - - @Test("A conversation with no counterpart at all renders the same way") - func missingMember_fallsBackToNone() { - #expect(ConversationScreen.tipDMCounterpart(nil) == ChatProfileCard.Counterpart.none) - } -} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift deleted file mode 100644 index 58ba1e0e5..000000000 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// ChatGroupCardCell.swift -// FlipcashUI -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -#if canImport(UIKit) -import UIKit -import SwiftUI -import FlipcashCore - -/// The group's card at the head of its transcript: the chat's picture, its title, and the rule it -/// runs on. SwiftUI content hosted in the cell so the picture is the same `ContactAvatarView` the -/// navigation title renders. -public final class ChatGroupCardCell: UICollectionViewCell { - - public static let reuseIdentifier = "ChatGroupCardCell" - - /// Fills the cell. `inviteCardWidth` is the width the transcript gives a link card, so the - /// invite card here matches the same card sent in a chat. - public func configure( - with card: ChatGroupCard, - inviteCardWidth: CGFloat, - onTap: (() -> Void)? = nil, - onInvite: (() -> Void)? = nil - ) { - contentConfiguration = UIHostingConfiguration { - GroupCardView(card: card, inviteCardWidth: inviteCardWidth, onTap: onTap, onInvite: onInvite) - } - .margins(.all, 0) - } -} - -/// The card body: the same card a group's invite link renders as in a transcript (node -/// 10330:19164), with CTA "Invite People" for a member, opening the invite sheet, and no button for -/// anyone else. -struct GroupCardView: View { - - let card: ChatGroupCard - /// The card's width: a link card's width in the transcript (node 10330:19164). - var inviteCardWidth: CGFloat = 290 - /// Opens the chat's own profile; nil leaves the card inert. - var onTap: (() -> Void)? - /// Hands out the chat's invite link. The button is drawn only when the card asks for it. - var onInvite: (() -> Void)? - - var body: some View { - LinkGroupCardContent( - state: .resolved(.init( - title: card.title, - memberCount: card.memberCount, - avatarID: card.avatarID, - imageData: card.imageData, - blurHash: card.blurhash, - requirement: card.requirement - )), - ctaTitle: showsInvite ? LinkGroupCardContent.Copy.invite : nil, - onAction: onInvite ?? {}, - ctaAccessibilityIdentifier: "group-card-invite", - onTapCard: onTap - ) - .frame(width: inviteCardWidth) - .frame(maxWidth: .infinity) - .padding(.top, 8) - } - - private var showsInvite: Bool { - card.showsInvite && onInvite != nil - } - - /// Node 10125:19157's metrics, which the link card still sizes itself from. - enum Layout { - static let radius: CGFloat = 12 - static let borderOpacity: Double = 0.1 - static let horizontalPadding: CGFloat = 12 - static let titleGap: CGFloat = 13 - static let requirementGap: CGFloat = 11 - } -} - -#Preview("Member / not a member") { - VStack(spacing: 12) { - GroupCardView(card: ChatGroupCard( - title: "BadBoys", - avatarID: "badboys", - requirement: "Balance Requirement:\n$100.00 of $BadBoys", - showsInvite: true - ), onTap: {}, onInvite: {}) - GroupCardView(card: ChatGroupCard( - title: "Ballers", - avatarID: "ballers", - requirement: "Balance Requirement:\n$100.00 of $BadBoys" - ), onTap: {}) - GroupCardView(card: ChatGroupCard(title: "Flipcash Staff", avatarID: "staff")) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.backgroundMain) -} -#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index dfb246104..944f0ca4f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -27,10 +27,6 @@ extension ChatItem { switch self { case .typingIndicator: ChatTypingIndicatorCell.reuseIdentifier - case .profileCard: - ChatProfileCardCell.reuseIdentifier - case .groupCard: - ChatGroupCardCell.reuseIdentifier case .dateSeparator: ChatDateSeparatorCell.reuseIdentifier case .unreadDivider: diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift deleted file mode 100644 index 25be7da2e..000000000 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// ChatProfileCardCell.swift -// FlipcashUI -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -#if canImport(UIKit) -import UIKit -import SwiftUI -import FlipcashCore - -/// The counterpart's profile card at the head of a short transcript: avatar, name, and a -/// call to action that opens their contact card — or adds them, when they're not a contact. -/// SwiftUI content hosted in the cell so the avatar is the same `ContactAvatarView` the -/// navigation title renders. -public final class ChatProfileCardCell: UICollectionViewCell { - - public static let reuseIdentifier = "ChatProfileCardCell" - - public func configure(with card: ChatProfileCard, onContactAction: @escaping () -> Void, onProfileTap: (() -> Void)? = nil) { - contentConfiguration = UIHostingConfiguration { - ProfileCardView(card: card, onContactAction: onContactAction, onProfileTap: onProfileTap) - } - .margins(.all, 0) - } -} - -/// The card body. The pill leads to the same place the navigation title does, so the card is a -/// bigger door to an existing room. -private struct ProfileCardView: View { - - let card: ChatProfileCard - let onContactAction: () -> Void - let onProfileTap: (() -> Void)? - - var body: some View { - VStack(spacing: 0) { - header - - ProfileCardSubtitle(counterpart: card.counterpart) - .font(.appTextSmall) - .padding(.top, 4) - - switch card.counterpart { - case .contact, .unknown: - ContactActionPill(counterpart: card.counterpart, action: onContactAction) - .padding(.top, 16) - case .handle, .none: - EmptyView() - } - } - .padding(.horizontal, 12) - .padding(.vertical, 20) - // Min, not fixed: the app fonts scale with Dynamic Type, so the card grows past its - // design height at accessibility sizes instead of clipping. - .frame(width: 230) - .frame(minHeight: 250) - .background { - RoundedRectangle(cornerRadius: 26, style: .continuous) - .strokeBorder(Color.white.opacity(0.08)) - } - .frame(maxWidth: .infinity) - .padding(.top, 8) - } - - @ViewBuilder private var header: some View { - let content = VStack(spacing: 0) { - ContactAvatarView( - id: card.avatarID, - displayName: card.name, - imageData: card.imageData, - blurhash: card.blurhash, - size: 80 - ) - .accessibilityHidden(true) - - HStack(spacing: 4) { - Text(card.name) - .font(.appTextLarge) - .foregroundStyle(Color.textMain) - .lineLimit(1) - if onProfileTap != nil { - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(Color.textSecondary) - } - } - .padding(.top, 12) - } - if let onProfileTap { - Button(action: onProfileTap) { content }.buttonStyle(.plain) - } else { - content - } - } - -} - -/// The line under the name: the contact's number, or the "Unknown Contact" flag. -private struct ProfileCardSubtitle: View { - - let counterpart: ChatProfileCard.Counterpart - - var body: some View { - switch counterpart { - case .contact(let phone): - Text(phone) - .foregroundStyle(Color.textSecondary) - case .unknown: - Text("Unknown Contact") - .foregroundStyle(Color.warning) - case .handle(let username): - Text(username.handle) - .foregroundStyle(Color.textSecondary) - case .none: - // No relationship to surface — renders nothing. - EmptyView() - } - } -} - -#Preview("Known / unknown") { - VStack(spacing: 12) { - ProfileCardView( - card: ChatProfileCard( - name: "Ted Livingston", - avatarID: "ted", - imageData: nil, - counterpart: .contact(phone: "519 802-3885") - ), - onContactAction: {}, - onProfileTap: nil - ) - ProfileCardView( - card: ChatProfileCard( - name: "519 802-3885", - avatarID: "unknown", - imageData: nil, - counterpart: .unknown - ), - onContactAction: {}, - onProfileTap: nil - ) - ProfileCardView( - card: ChatProfileCard( - name: "Raul", - avatarID: "raul", - imageData: nil, - counterpart: .none - ), - onContactAction: {}, - onProfileTap: nil - ) - ProfileCardView( - card: ChatProfileCard( - name: "Fred Wilson", - avatarID: "fred", - imageData: nil, - counterpart: .handle(Username("fred_wilson")!) - ), - onContactAction: {}, - onProfileTap: nil - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.backgroundMain) -} -#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 0e559c536..aedd82fea 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -236,11 +236,6 @@ public final class ChatScreenViewController: UIViewController { set { transcript.pendingMediaProgress = newValue } } - public var onContactAction: (() -> Void)? { - get { transcript.onContactAction } - set { transcript.onContactAction = newValue } - } - /// Avatar bytes for the transcript's authors, keyed by user id — see /// ``ChatViewController/authorAvatars``. public var authorAvatars: [UserID: Data] { @@ -248,17 +243,6 @@ public final class ChatScreenViewController: UIViewController { set { transcript.authorAvatars = newValue } } - /// Forwards head-card taps from the transcript to the owner. - public var onProfileTap: (() -> Void)? { - get { transcript.onProfileTap } - set { transcript.onProfileTap = newValue } - } - - /// Forwards the group head card's invite tap from the transcript to the owner. - public var onGroupInvite: (() -> Void)? { - get { transcript.onGroupInvite } - set { transcript.onGroupInvite = newValue } - } /// Forwards the "Encrypted" marker's tap from the transcript to the owner. public var onEncryptionMarkerTap: (() -> Void)? { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScrollBenchmark.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScrollBenchmark.swift index 13471c08d..95190b5a7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScrollBenchmark.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScrollBenchmark.swift @@ -27,7 +27,7 @@ public final class ChatScrollBenchmarkViewController: UIViewController { /// How many message rows the window holds. public var messageCount: Int /// How many distinct authors the rows round-robin over; 0 builds a DM-shaped window with no - /// author attribution and no head card. + /// author attribution. public var authorCount: Int /// Points per second the scripted scroll travels at. 2000 is a hard flick. public var velocity: CGFloat @@ -252,14 +252,7 @@ public final class ChatScrollBenchmarkViewController: UIViewController { let authors = self.authors(count: configuration.authorCount) var items: [ChatItem] = [] - items.reserveCapacity(total + 1) - if !authors.isEmpty { - items.append(.groupCard(ChatGroupCard( - title: "Flipcash Staff", - avatarID: "benchmark-group", - requirement: "Minimum Balance: $1.00 of Jeffy" - ))) - } + items.reserveCapacity(total) // The harness builds messages directly rather than through `ChatItem.from`, so it has to // reach the same two conclusions the mapper does: which rows render bare, and where that diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 789fa8f8f..1d2fa42ad 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -82,17 +82,6 @@ public final class ChatViewController: UICollectionViewController { /// carries no resolution state and a lookup landing cannot change a row's diff. public weak var linkCardSource: (any LinkCardSource)? - /// Called when the user taps the profile card's call to action; the owner opens the - /// counterpart's contact card (or the add-contact sheet), same as the nav title. - public var onContactAction: (() -> Void)? - - /// Called when the user taps the transcript's head card — the counterpart's in a tip DM, the - /// chat's own in a group. The owner opens that subject's profile; nil disables the tap. - public var onProfileTap: (() -> Void)? - - /// Called when the user taps the group card's "Invite People"; the owner hands out the - /// chat's invite link. nil leaves the card without the offer. - public var onGroupInvite: (() -> Void)? /// Called when the "Encrypted" marker is tapped; nil leaves it inert. public var onEncryptionMarkerTap: (() -> Void)? @@ -155,7 +144,7 @@ public final class ChatViewController: UICollectionViewController { configure(cell, with: message) case .typingIndicator(let typists): (cell as? ChatTypingIndicatorCell)?.configure(typists: typists, imageData: authorAvatars) - case .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: + case .dateSeparator, .unreadDivider, .encryptionMarker: continue } } @@ -344,8 +333,6 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) collectionView.register(ChatEncryptionMarkerCell.self, forCellWithReuseIdentifier: ChatEncryptionMarkerCell.reuseIdentifier) collectionView.register(ChatTypingIndicatorCell.self, forCellWithReuseIdentifier: ChatTypingIndicatorCell.reuseIdentifier) - collectionView.register(ChatProfileCardCell.self, forCellWithReuseIdentifier: ChatProfileCardCell.reuseIdentifier) - collectionView.register(ChatGroupCardCell.self, forCellWithReuseIdentifier: ChatGroupCardCell.reuseIdentifier) swipeToReply.isBlocked = { [weak self] in guard let self else { return true } @@ -531,7 +518,7 @@ public final class ChatViewController: UICollectionViewController { let arrivals = (start.. (row: Int, message: ChatMessage)? in switch new[row] { case .message(let message): (row, message) - case .typingIndicator, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: nil + case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker: nil } } guard let taker = TypingDotsHandoff.takerIndex( @@ -558,7 +545,7 @@ public final class ChatViewController: UICollectionViewController { switch item { case .typingIndicator(let typists): typists.isEmpty ? [.counterpart] : Set(typists.map { .member($0.id) }) - case .message, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: + case .message, .dateSeparator, .unreadDivider, .encryptionMarker: nil } } @@ -574,7 +561,7 @@ public final class ChatViewController: UICollectionViewController { guard items.indices.contains(index) else { return nil } switch items[index] { case .message(let message): return message - case .typingIndicator, .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: return nil + case .typingIndicator, .dateSeparator, .unreadDivider, .encryptionMarker: return nil } } @@ -608,23 +595,6 @@ public final class ChatViewController: UICollectionViewController { switch item { case .typingIndicator(let typists): (cell as! ChatTypingIndicatorCell).configure(typists: typists, imageData: authorAvatars) - case .profileCard(let card): - let profileTap: (() -> Void)? = onProfileTap == nil ? nil : { [weak self] in self?.onProfileTap?() } - (cell as! ChatProfileCardCell).configure( - with: card, - onContactAction: { [weak self] in self?.onContactAction?() }, - onProfileTap: profileTap - ) - case .groupCard(let card): - let cardTap: (() -> Void)? = onProfileTap == nil ? nil : { [weak self] in self?.onProfileTap?() } - let invite: (() -> Void)? = onGroupInvite == nil ? nil : { [weak self] in self?.onGroupInvite?() } - let width = collectionView.bounds.width > 0 ? collectionView.bounds.width : UIScreen.main.bounds.width - (cell as! ChatGroupCardCell).configure( - with: card, - inviteCardWidth: width * Self.maxBubbleWidthFraction, - onTap: cardTap, - onInvite: invite - ) case .dateSeparator(_, let text): (cell as! ChatDateSeparatorCell).configure(text: text) case .unreadDivider(let count): @@ -1317,7 +1287,7 @@ extension ChatViewController: ChatLayoutDelegate { guard items.indices.contains(indexPath.item) else { return false } switch items[indexPath.item] { case .dateSeparator, .unreadDivider, .encryptionMarker: return true - case .message, .typingIndicator, .profileCard, .groupCard: return false + case .message, .typingIndicator: return false } } @@ -1334,7 +1304,7 @@ extension ChatViewController: ChatLayoutDelegate { switch items[indexPath.item] { case .message(let message): return message.sender case .typingIndicator: return .other - case .dateSeparator, .unreadDivider, .profileCard, .groupCard, .encryptionMarker: return nil + case .dateSeparator, .unreadDivider, .encryptionMarker: return nil } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift index 2af18d750..6556ee0f7 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift @@ -16,8 +16,8 @@ import FlipcashCore /// Built from the chat's public record only. The roster is private to members whatever the group's /// mode, so there are no member avatars here and no names. /// -/// SwiftUI content hosted in a UIKit view, for the same reason as ``ChatGroupCardCell``: the picture -/// is the same `ContactAvatarView` the chat's own head card draws. Only the button takes a tap; it +/// SwiftUI content hosted in a UIKit view, so the picture is the same `ContactAvatarView` the rest of +/// the app draws. Only the button takes a tap; it /// routes through the chat link, whose screen offers the join or the buy itself. final class LinkGroupCardView: UIView { @@ -144,37 +144,41 @@ final class LinkGroupCardView: UIView { } } -/// The card's body, per the group head card (nodes 10125:19157, 10127:118280, 10127:116723): the -/// same radius, ring, avatar ring, title and requirement styling, with a tinted band across the top. -/// -/// Shared by two hosts rather than forked between them: ``LinkGroupCardView`` draws it wherever a -/// group's invite link appears in a transcript (CTA "View", into the group), and -/// ``ChatGroupCardCell``'s ``GroupCardView`` draws it at the head of a group's own transcript (CTA -/// "Invite People" for a member, into the invite sheet; no button for anyone else). The CTA's label -/// and action are the only things that differ between the two, so they are the only things passed in. +/// The metrics both link cards share, from the group card's design (node 10125:19157). +enum LinkCardMetrics { + static let radius: CGFloat = 12 + static let borderOpacity: Double = 0.1 + static let horizontalPadding: CGFloat = 12 + static let titleGap: CGFloat = 13 + static let requirementGap: CGFloat = 11 +} + +/// The card's body (nodes 10125:19157, 10127:118280, 10127:116723): picture, title, member count and +/// requirement under a tinted band, with a button into the group. ``LinkGroupCardView`` draws it +/// wherever a group's invite link appears in a transcript. struct LinkGroupCardContent: View { let state: LinkCard.Group.State /// The card's outline: its place in a bubble run inside a transcript, every corner at the head /// card's radius on its own. var cornerRadii = RectangleCornerRadii( - topLeading: GroupCardView.Layout.radius, - bottomLeading: GroupCardView.Layout.radius, - bottomTrailing: GroupCardView.Layout.radius, - topTrailing: GroupCardView.Layout.radius + topLeading: LinkCardMetrics.radius, + bottomLeading: LinkCardMetrics.radius, + bottomTrailing: LinkCardMetrics.radius, + topTrailing: LinkCardMetrics.radius ) - /// The button's label — "View" from a transcript link, "Invite People" from a group's own head - /// card. Nil leaves the button out of a resolved card, which is then as tall as its content. + /// The button's label. Nil leaves the button out of a resolved card, which is then as tall as + /// its content. var ctaTitle: String? = Copy.view /// Called when the button is tapped. var onAction: () -> Void = {} /// The button's UI-test handle, which differs by host. var ctaAccessibilityIdentifier: String = "group-card-cta" - /// Taps the picture/title band open, same as the head card's own chevron used to. Nil leaves - /// the band inert, as it is inline in a transcript, where only the button leads anywhere. + /// Taps the picture/title band open. Nil leaves the band inert, as it is inline in a + /// transcript, where only the button leads anywhere. var onTapCard: (() -> Void)? = nil - /// Values this card adds to the head card's ``GroupCardView/Layout``. Named so Android can copy + /// Values this card adds to ``LinkCardMetrics``. Named so Android can copy /// them one for one. enum Layout { /// The tinted band across the top of the card. @@ -193,8 +197,6 @@ struct LinkGroupCardContent: View { enum Copy { /// A group's invite link, met in a transcript. static let view = "View" - /// A group's own head card, for a member. - static let invite = "Invite People" static let unavailable = "Group Unavailable" } @@ -204,7 +206,7 @@ struct LinkGroupCardContent: View { if case .resolved(let group) = state { text(for: group) - .padding(.horizontal, GroupCardView.Layout.horizontalPadding) + .padding(.horizontal, LinkCardMetrics.horizontalPadding) // Measured at its ideal height whatever the row proposes: under a short // proposal `Text` drops the requirement's second line for an ellipsis. .fixedSize(horizontal: false, vertical: true) @@ -214,17 +216,17 @@ struct LinkGroupCardContent: View { Spacer(minLength: Layout.buttonGap) button - .padding(.horizontal, GroupCardView.Layout.horizontalPadding) - .padding(.bottom, GroupCardView.Layout.horizontalPadding) + .padding(.horizontal, LinkCardMetrics.horizontalPadding) + .padding(.bottom, LinkCardMetrics.horizontalPadding) } else { - Spacer(minLength: GroupCardView.Layout.horizontalPadding) + Spacer(minLength: LinkCardMetrics.horizontalPadding) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .clipShape(UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous)) .overlay { UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous) - .strokeBorder(Color.white.opacity(GroupCardView.Layout.borderOpacity)) + .strokeBorder(Color.white.opacity(LinkCardMetrics.borderOpacity)) } } @@ -234,7 +236,7 @@ struct LinkGroupCardContent: View { .font(.appTextLarge) .foregroundStyle(Color.textMain) .lineLimit(1) - .padding(.top, GroupCardView.Layout.titleGap) + .padding(.top, LinkCardMetrics.titleGap) Text(group.memberCount) .font(.default(size: 13, weight: .medium)) @@ -246,7 +248,7 @@ struct LinkGroupCardContent: View { .font(.default(size: 15, weight: .medium)) .foregroundStyle(Color.textMain.opacity(0.5)) .multilineTextAlignment(.center) - .padding(.top, GroupCardView.Layout.requirementGap) + .padding(.top, LinkCardMetrics.requirementGap) } } } @@ -267,7 +269,7 @@ struct LinkGroupCardContent: View { blurhash: group.blurHash, size: Layout.avatar ) - .overlay { Circle().strokeBorder(Color.white.opacity(GroupCardView.Layout.borderOpacity)) } + .overlay { Circle().strokeBorder(Color.white.opacity(LinkCardMetrics.borderOpacity)) } // Centred on the band's bottom edge. .padding(.top, Layout.bandHeight - Layout.avatar / 2) } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkUserCardView+Previews.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkUserCardView+Previews.swift index a4ccb0730..804183da9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkUserCardView+Previews.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkUserCardView+Previews.swift @@ -177,7 +177,7 @@ private struct LinkCardShimmerSample: UIViewRepresentable { ground: UIColor(Color.backgroundRow), highlight: UIColor.white.withAlphaComponent(0.06) ) - view.layer.cornerRadius = GroupCardView.Layout.radius + view.layer.cornerRadius = LinkCardMetrics.radius view.layer.cornerCurve = .continuous view.setShimmering(true) return view diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkUserCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkUserCardView.swift index 9406c57de..bd67ba283 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkUserCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkUserCardView.swift @@ -281,7 +281,7 @@ struct LinkUserCardContent: View { .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading) .background { backdrop } .clipShape(outline) - .overlay { outline.strokeBorder(Color.white.opacity(GroupCardView.Layout.borderOpacity)) } + .overlay { outline.strokeBorder(Color.white.opacity(LinkCardMetrics.borderOpacity)) } .contentShape(outline) } @@ -302,7 +302,7 @@ struct LinkUserCardContent: View { ContactAvatarView(id: "", displayName: "", size: Layout.avatar) } } - .overlay { Circle().strokeBorder(Color.white.opacity(GroupCardView.Layout.borderOpacity)) } + .overlay { Circle().strokeBorder(Color.white.opacity(LinkCardMetrics.borderOpacity)) } } private var identity: some View { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactActionPill.swift b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactActionPill.swift deleted file mode 100644 index f1d049203..000000000 --- a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactActionPill.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// ContactActionPill.swift -// FlipcashUI -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import SwiftUI -import FlipcashCore - -/// The counterpart call to action shared by the chat profile card and the profile page: -/// "View in Contacts" for a synced contact, "Add Contact" for an unknown one. -public struct ContactActionPill: View { - - let counterpart: ChatProfileCard.Counterpart - let action: () -> Void - - public init(counterpart: ChatProfileCard.Counterpart, action: @escaping () -> Void) { - self.counterpart = counterpart - self.action = action - } - - public var body: some View { - Button(action: action) { - ContactActionLabel(counterpart: counterpart) - .font(.appTextSmall) - .foregroundStyle(Color.textMain) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background { - Capsule().fill(Color.white.opacity(0.1)) - } - } - .buttonStyle(.plain) - } -} - -/// The pill's label: view the existing contact, or add the unknown one. -private struct ContactActionLabel: View { - - let counterpart: ChatProfileCard.Counterpart - - var body: some View { - switch counterpart { - case .contact: - Text("View in Contacts") - case .unknown: - Label { - Text("Add Contact") - } icon: { - Image.system(.personBadgePlus) - } - case .handle, .none: - // Carries no contact action; the hosting card omits the pill entirely. - EmptyView() - } - } -} diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index 7cd916360..62ad6030b 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -35,7 +35,7 @@ struct NotificationTranscriptView: View { case .dateSeparator(_, let text): NotificationDateSeparator(text: text) .transition(.opacity) - case .typingIndicator, .profileCard, .groupCard, .unreadDivider, .encryptionMarker: + case .typingIndicator, .unreadDivider, .encryptionMarker: // Typing is live, ephemeral state; the profile card and the // unread divider are in-app chrome — none belongs in a static // notification snapshot and the preview mapping never emits