From 1b12a384aeac9ba10e7d7a7e5ab0a4fb7117268f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 08:11:44 -0400 Subject: [PATCH] fix(chat): head every group with its invite card A group's transcript opened with the invite link card only while the viewer was its sole member. Once anyone else joined, the head fell back to the older bordered card, and the invite moved to the chat's profile. The head card is now the link card in every group. A member gets the "Invite People" button however many people have joined. Someone who hasn't joined has no link to hand out, so their card has no button and shrinks to its content. LinkGroupCardContent takes an optional CTA title for that case. The bordered card and the layout values only it used are gone. Android made the same change in code-payments/code-android-app#1581. --- .../Conversation/ConversationScreen.swift | 14 +- .../Screens/Profile/ChatProfileScreen.swift | 3 +- .../Models/Chat/ChatGroupCard.swift | 7 +- .../FlipcashUI/Chat/ChatGroupCardCell.swift | 133 ++++-------------- .../FlipcashUI/Chat/LinkGroupCardView.swift | 41 ++++-- 5 files changed, 66 insertions(+), 132 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index ee9bf6f92..0c43c9f10 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -84,8 +84,8 @@ struct ConversationScreen: View { @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 the empty group's - /// card hands one out (node 10127:118280). + /// 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 @State private var messageReport: MessageReportRequest? /// Whether `startSendCash` has been acted on, so a re-render or a return from the sheet it @@ -396,15 +396,11 @@ struct ConversationScreen: View { ) } - /// Whether the head card offers the invite link: a group the viewer belongs to that nobody else - /// has joined. Once someone else is in, the invite lives on the chat's profile instead, so it - /// stops competing with the transcript (node 10127:118280). - /// - /// Counted from ``ConversationRosterSummary/memberCount`` for the reason ``titleSubtitle`` is — - /// an embedded roster is a subset. + /// 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) && group.rosterSummary.memberCount <= 1 + return conversationController.isMember(of: group) } /// The chat's entry rule as the card states it (node 10125:19164), or nil when the chat states diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 65305b848..d1dbeac0b 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -13,8 +13,7 @@ import FlipcashUI /// chat's head card or its navigation title, the way a DM's title opens the counterpart's profile. /// /// It carries the actions a member has over a group: handing out the invite link, silencing its -/// notifications, and leaving. The head card offers the invite as well, but only while the group is -/// still empty (node 10127:118280), so once anyone else has joined this is the only way to the link. +/// notifications, and leaving. The head card at the top of the transcript offers the invite as well. struct ChatProfileScreen: View { let conversationID: ConversationID diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift index c85bfc5a6..bf1fa287f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatGroupCard.swift @@ -20,7 +20,7 @@ public struct ChatGroupCard: Hashable, Sendable, Codable { 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 empty-state card reads identically to either. + /// 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. @@ -34,9 +34,8 @@ public struct ChatGroupCard: Hashable, Sendable, Codable { /// a member too (node 10125:19256). public var requirement: String? - /// Whether the card offers "Invite People" under the requirement (node 10127:118280). - /// Set for a group the viewer is a member of that no one else has joined yet: a link is the only - /// way into a group, so an empty one needs its own way to hand that link out. + /// 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) { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift index 56e69b710..58ba1e0e5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatGroupCardCell.swift @@ -32,140 +32,67 @@ public final class ChatGroupCardCell: UICollectionViewCell { } } -/// The card body, per node 10125:19157. +/// 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 invite card's width: a link card's width in the transcript (node 10330:19164). + /// 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. Drawn only when the card asks for it. + /// 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 { - Group { - if card.showsInvite, let onInvite { - // The same card a group's invite link renders as elsewhere (node 10330:19164) — - // CTA "Invite People" rather than "View", opening the invite sheet rather than the - // chat itself. - LinkGroupCardContent( - state: .resolved(.init( - title: card.title, - memberCount: card.memberCount, - avatarID: card.avatarID, - imageData: card.imageData, - blurHash: card.blurhash, - requirement: card.requirement - )), - ctaTitle: LinkGroupCardContent.Copy.invite, - onAction: onInvite, - ctaAccessibilityIdentifier: "group-card-invite", - onTapCard: onTap - ) - .frame(width: inviteCardWidth) - } else { - cardBody - } - } + 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) } - /// The bordered card itself: everything the group states about itself. The invite button sits - /// outside it — it is an action the viewer takes, not a fact about the chat (node 10127:118280). - private var cardBody: some View { - VStack(spacing: 0) { - header - - if let requirement = card.requirement { - Text(requirement) - .font(.default(size: 15, weight: .medium)) - .foregroundStyle(Color.textMain.opacity(0.5)) - .multilineTextAlignment(.center) - .padding(.top, Layout.requirementGap) - } - } - .padding(.horizontal, Layout.horizontalPadding) - .padding(.top, Layout.topPadding) - .padding(.bottom, Layout.bottomPadding) - // 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: Layout.width) - .frame(minHeight: Layout.minHeight) - .background { - RoundedRectangle(cornerRadius: Layout.radius, style: .continuous) - .strokeBorder(Color.white.opacity(Layout.borderOpacity)) - } - .accessibilityElement(children: .combine) - } - - /// The picture and the title, with the chevron that says the card opens something (node - /// 10127:116723). The requirement line below stays outside the tap target — it states a rule, - /// it does not lead anywhere. - @ViewBuilder private var header: some View { - let content = VStack(spacing: 0) { - ContactAvatarView( - id: card.avatarID, - displayName: card.title, - imageData: card.imageData, - blurhash: card.blurhash, - size: Layout.avatar - ) - .overlay { Circle().strokeBorder(Color.white.opacity(Layout.borderOpacity)) } - .accessibilityHidden(true) - - HStack(spacing: 6) { - Text(card.title) - .font(.appTextLarge) - .foregroundStyle(Color.textMain) - .lineLimit(1) - if onTap != nil { - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(Color.textSecondary) - } - } - .padding(.top, Layout.titleGap) - } - if let onTap { - Button(action: onTap) { content }.buttonStyle(.plain) - } else { - content - } + private var showsInvite: Bool { + card.showsInvite && onInvite != nil } - /// Node 10125:19157 — a 210×228 card, its column starting 31pt down, 13pt under the 80pt - /// picture and the requirement line centred at 177pt. - /// Internal so the group invite link card sizes itself from the same values. + /// Node 10125:19157's metrics, which the link card still sizes itself from. enum Layout { - static let width: CGFloat = 210 - static let minHeight: CGFloat = 228 static let radius: CGFloat = 12 static let borderOpacity: Double = 0.1 - static let avatar: CGFloat = 80 - static let topPadding: CGFloat = 31 - static let bottomPadding: CGFloat = 24 static let horizontalPadding: CGFloat = 12 static let titleGap: CGFloat = 13 static let requirementGap: CGFloat = 11 } } -#Preview("Gated / open") { +#Preview("Member / not a member") { VStack(spacing: 12) { - GroupCardView(card: ChatGroupCard( - title: "Ballers", - avatarID: "ballers", - requirement: "Balance Requirement:\n$100.00 of $BadBoys" - ), onTap: {}) - GroupCardView(card: ChatGroupCard(title: "Flipcash Staff", avatarID: "staff")) 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) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift index e79de4d24..2af18d750 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkGroupCardView.swift @@ -149,9 +149,9 @@ final class LinkGroupCardView: UIView { /// /// 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 while -/// it is still empty (CTA "Invite People", into the invite sheet). The CTA's label and action are -/// the only things that differ between the two, so they are the only things passed in. +/// ``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. struct LinkGroupCardContent: View { let state: LinkCard.Group.State @@ -163,9 +163,9 @@ struct LinkGroupCardContent: View { bottomTrailing: GroupCardView.Layout.radius, topTrailing: GroupCardView.Layout.radius ) - /// The button's label — "View" from a transcript link, "Invite People" from a group's own - /// empty-state head card. - var ctaTitle: String = Copy.view + /// 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. + var ctaTitle: String? = Copy.view /// Called when the button is tapped. var onAction: () -> Void = {} /// The button's UI-test handle, which differs by host. @@ -193,7 +193,7 @@ struct LinkGroupCardContent: View { enum Copy { /// A group's invite link, met in a transcript. static let view = "View" - /// A group's own empty-state head card. + /// A group's own head card, for a member. static let invite = "Invite People" static let unavailable = "Group Unavailable" } @@ -210,11 +210,15 @@ struct LinkGroupCardContent: View { .fixedSize(horizontal: false, vertical: true) } - Spacer(minLength: Layout.buttonGap) + if showsButton { + Spacer(minLength: Layout.buttonGap) - button - .padding(.horizontal, GroupCardView.Layout.horizontalPadding) - .padding(.bottom, GroupCardView.Layout.horizontalPadding) + button + .padding(.horizontal, GroupCardView.Layout.horizontalPadding) + .padding(.bottom, GroupCardView.Layout.horizontalPadding) + } else { + Spacer(minLength: GroupCardView.Layout.horizontalPadding) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .clipShape(UnevenRoundedRectangle(cornerRadii: cornerRadii, style: .continuous)) @@ -275,6 +279,13 @@ struct LinkGroupCardContent: View { } } + /// An unavailable card always says so on its disabled button; a resolved one has a button only + /// when it's given a label. + private var showsButton: Bool { + if case .resolved = state { return ctaTitle != nil } + return true + } + @ViewBuilder private var button: some View { switch state { case .unavailable: @@ -282,9 +293,11 @@ struct LinkGroupCardContent: View { .buttonStyle(.filled20Compact) .disabled(true) case .resolved: - Button(ctaTitle, action: onAction) - .buttonStyle(.filledCompact) - .accessibilityIdentifier(ctaAccessibilityIdentifier) + if let ctaTitle { + Button(ctaTitle, action: onAction) + .buttonStyle(.filledCompact) + .accessibilityIdentifier(ctaAccessibilityIdentifier) + } } }