From 503c48e8ac99e686fe7ce6190440fb02be7808ed Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 12:42:13 -0400 Subject: [PATCH] refactor(chat): remove the card at the head of chat history Match code-android-app#1707: a DM no longer opens with the counterpart's profile card, and a group no longer opens with its own card and Invite People. The nav title still opens the profile, and Share on the group profile still hands out the invite link. Android added a spacer in their place because its long-press menu put the reaction strip under the top bar on the oldest message. iOS lifts the message into its own window clamped to the safe area, so no spacer here. The link card metrics the head card owned move to LinkCardMetrics, and the group card's token lookup goes with it. --- .../Navigation/AppRouter+Destination.swift | 2 +- .../ChatScreenRepresentable.swift | 15 -- .../Conversation/ConversationGate.swift | 3 +- .../ConversationLoadCoordinator.swift | 26 +-- .../Conversation/ConversationScreen.swift | 175 +----------------- .../Screens/Profile/ChatProfileScreen.swift | 4 +- .../Models/Chat/ChatGroupCard.swift | 50 ----- .../FlipcashCore/Models/Chat/ChatItem.swift | 9 +- .../Models/Chat/ChatProfileCard.swift | 46 ----- .../FlipcashCore/Models/Chat/LinkCard.swift | 2 +- .../Chat/ChatCashCardSizingTests.swift | 6 - .../ConversationTypingIndicatorTests.swift | 7 +- .../ChatProfileCardCounterpartTests.swift | 37 ---- .../FlipcashUI/Chat/ChatGroupCardCell.swift | 100 ---------- .../Chat/ChatItem+Differentiable.swift | 4 - .../FlipcashUI/Chat/ChatProfileCardCell.swift | 169 ----------------- .../Chat/ChatScreenViewController.swift | 16 -- .../FlipcashUI/Chat/ChatScrollBenchmark.swift | 11 +- .../FlipcashUI/Chat/ChatViewController.swift | 42 +---- .../FlipcashUI/Chat/LinkGroupCardView.swift | 60 +++--- .../Chat/LinkUserCardView+Previews.swift | 2 +- .../FlipcashUI/Chat/LinkUserCardView.swift | 4 +- .../Views/Contacts/ContactActionPill.swift | 58 ------ .../NotificationTranscriptView.swift | 2 +- 24 files changed, 62 insertions(+), 788 deletions(-) delete mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift delete mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift delete mode 100644 FlipcashTests/ChatProfileCardCounterpartTests.swift delete mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift delete mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift delete mode 100644 FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactActionPill.swift diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index f431a689e..8222146e0 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -116,7 +116,7 @@ extension AppRouter { /// A person's Flipcash profile, pushed from a tip DM's title/card or a group member's face; /// hosts the Block action. `origin` decides whether it offers a way into the DM. case userProfile(UserID, origin: UserProfileOrigin) - /// A group chat's own profile, pushed from its head card or its navigation title. + /// A group chat's own profile, pushed from its navigation title. case chatProfile(ConversationID) /// What an editor can change about a group, pushed from the chat profile's overflow menu. /// Only reachable while ``Conversation/canEdit`` holds. 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 867e42e65..deacd4eef 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/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index a5570a85d..670fc7a8c 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -14,7 +14,7 @@ import FlipcashUI /// picture and description, who is chatting, what it takes to join and to chat, and one pinned /// button that joins, buys in, or opens the chat. /// -/// Reached by tapping the chat's head card or its navigation title, the way a DM's title opens the +/// Reached by tapping the chat's navigation title, the way a DM's title opens the /// counterpart's profile. The actions a member has over the group sit in the ⋯ menu, and Leave Chat /// sits under the pinned button. struct ChatProfileScreen: View { @@ -372,7 +372,7 @@ struct ChatProfileScreen: View { case .join: join() case .openChat: - // The only way here is the chat's own head card or title, so the chat is underneath. + // Reached from the chat's title, so the chat is underneath. router.popTopmost() case .buyToJoin(_, let mint), .buyToChat(_, let mint): buy(mint) 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