From c125c1d1417d7ce7e4216734ff785e2c4230700c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 11:47:40 -0400 Subject: [PATCH 1/4] feat(chat): render share-profile widget messages as a bubble Carry widget messages through ConversationMessage instead of dropping them. A share-profile widget draws a card with the person's avatar, name and handle (looked up by username through the link-card feed) and a Share button that opens the share sheet on their public profile link. Unrecognized widget variants render the existing unsupported bubble. Adds a localized-style preview line and persists widgets in the conversation cache (schema 44). --- .../Controllers/ConversationController.swift | 10 +- .../Conversation/ChatItem+Conversation.swift | 25 +- .../ChatScreenRepresentable.swift | 4 + .../Conversation/ConversationScreen.swift | 13 +- .../Core/Spotlight/ChatSpotlightItem.swift | 3 +- Flipcash/Utilities/URL+Links.swift | 2 +- .../Models/Chat/ChatMessage.swift | 7 +- .../Models/Chat/ChatPreviewMapping.swift | 6 +- .../FlipcashCore/Models/Chat/ChatQuote.swift | 3 + .../FlipcashCore/Models/Chat/LinkCard.swift | 6 + .../Conversation/ConversationMessage.swift | 25 +- .../Conversation/MessageCapability.swift | 6 + .../FlipcashCore/Models/Username.swift | 6 + .../Push/NotificationPayload.swift | 2 +- .../Database+Conversations.swift | 11 + .../Sources/FlipcashStore/Database.swift | 2 +- .../ConversationModelMappingTests.swift | 23 +- .../ConversationStoreMutationTests.swift | 1 + .../Database+ConversationsTests.swift | 22 +- .../FlipcashUI/Chat/ChatBubbleView.swift | 8 +- .../Chat/ChatItem+Differentiable.swift | 2 + .../Chat/ChatScreenViewController.swift | 5 + .../Chat/ChatShareProfileCell.swift | 273 ++++++++++++++++++ .../FlipcashUI/Chat/ChatViewController.swift | 13 +- .../NotificationTranscriptView.swift | 3 + 25 files changed, 455 insertions(+), 26 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index f0fcc5671..09a2b9aea 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1344,6 +1344,14 @@ final class ConversationController { case .deleted: return nil + case .widget(.shareProfile): + if isFromSelf { return "You shared a profile" } + guard let senderName else { return "Shared a profile" } + return "\(senderName) shared a profile" + + case .widget(.unrecognized): + return nil + case .encrypted: // No plaintext to preview -- the row still surfaces (it's still the newest activity), // just with a blank subtitle, same as an empty text body above. @@ -1763,7 +1771,7 @@ final class ConversationController { case .text(let text): store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) - case .encrypted, .cash, .deleted: + case .encrypted, .cash, .deleted, .widget: if case .failure(let error) = Result(catching: { try pending.content.asProto() }) { logger.error("Cannot retry a send this client has no path to re-send", metadata: [ "conversationID": "\(conversationID)", diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 7e543c347..3bee725a3 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -151,7 +151,7 @@ extension ChatItem { func isEmojiOnlyBody(_ message: ConversationMessage) -> Bool { switch message.content { case .text(let text): EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted, .encrypted: false + case .cash, .deleted, .encrypted, .widget: false } } func rendersBare(_ message: ConversationMessage) -> Bool { @@ -164,7 +164,7 @@ extension ChatItem { let layouts = messages.map { message in switch message.content { case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard)) - case .cash, .deleted, .encrypted: [RowLayout(part: nil, text: nil, preview: nil)] + case .cash, .deleted, .encrypted, .widget: [RowLayout(part: nil, text: nil, preview: nil)] } } // A status line under a message sits between it and the next bubble, so it ends the bubble @@ -251,6 +251,11 @@ extension ChatItem { ? "You deleted this message" : "This message was deleted" ) + case .widget(.shareProfile(let share)): + content = .shareProfile(LinkCard.User(profileOf: share.username)) + case .widget(.unrecognized): + // A widget variant this build doesn't draw; a newer one will. + content = .unavailable(.updateApp) case .encrypted: // Still encrypted here means decryption failed; the awaiting ones were dropped above. content = .unavailable(.decryptFailure( @@ -407,6 +412,22 @@ extension ChatItem { kind: .unavailable, authorID: original.senderID ) + case .widget(.shareProfile): + return ChatQuote( + stableID: original.stableID, + authorName: authorName, + snippet: ChatQuote.sharedProfileSnippet, + kind: .text, + authorID: original.senderID + ) + case .widget(.unrecognized): + return ChatQuote( + stableID: nil, + authorName: authorName, + snippet: ChatQuote.unavailableSnippet, + kind: .unavailable, + authorID: original.senderID + ) case .encrypted: // No plaintext to preview -- same unavailable treatment as a quote whose original the // local database never saw. diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 32ebb9717..b101346fd 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -32,6 +32,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Fired when the user taps an `@handle` in a message. The owner looks the handle up and opens /// that person's profile. let onMentionTap: (Username) -> Void + /// Fired when the Share button on a shared-profile widget is tapped. + let onShareProfile: (LinkCard.User) -> Void /// Fired when the user taps the card drawn in place of a link, with the whole card and the /// stable id of the message it came on. The owner decides where it lands, which differs by kind. let onLinkCardTap: (LinkCard, String) -> Void @@ -134,6 +136,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onCashCardTap = onCashCardTap screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap + screen.onShareProfile = onShareProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource screen.onContactAction = onContactAction @@ -177,6 +180,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onCashCardTap = onCashCardTap screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap + screen.onShareProfile = onShareProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource screen.onContactAction = onContactAction diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index deb234980..e2bee9757 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -481,6 +481,7 @@ struct ConversationScreen: View { onCashCardTap: openCurrencyInfo, onOpenURL: openLink, onMentionTap: openMention, + onShareProfile: shareProfile, onLinkCardTap: openLinkCard, linkCardSource: sessionContainer.linkCardFeed, onContactAction: openContactCard, @@ -1074,7 +1075,9 @@ struct ConversationScreen: View { ) case .deleted: (ChatQuote.deletedSnippet, .unavailable) - case .encrypted: + case .widget(.shareProfile): + (ChatQuote.sharedProfileSnippet, .text) + case .encrypted, .widget(.unrecognized): (ChatQuote.unavailableSnippet, .unavailable) } } @@ -1161,7 +1164,7 @@ struct ConversationScreen: View { case .cash(let fiat): Analytics.tokenInfoOpened(from: .openedFromChat, mint: fiat.mint) router.push(.currencyInfo(fiat.mint)) - case .text, .deleted, .encrypted: + case .text, .deleted, .encrypted, .widget: break } } @@ -1275,6 +1278,12 @@ struct ConversationScreen: View { ).open(url) } + /// Opens the share sheet on the widget's person, with the public link their own You tab shares. + private func shareProfile(_ card: LinkCard.User) { + let item = TipCodeShareItem(url: card.url, title: card.linkedHandle ?? card.url.absoluteString, preview: nil) + ShareSheet.present(activityItem: item) { _ in } + } + /// Looks up a tapped `@handle` and opens whoever it names. Resolved on tap rather than as the /// message maps: a transcript full of handles would otherwise cost a lookup per handle for /// people nobody taps. diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 79542d376..2c9f080cf 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -59,7 +59,8 @@ nonisolated struct ChatSpotlightItem { switch message?.content { case .text(let text): text case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" - case .deleted, .encrypted, nil: nil + case .widget(.shareProfile): "Shared a profile" + case .deleted, .encrypted, .widget(.unrecognized), nil: nil } } diff --git a/Flipcash/Utilities/URL+Links.swift b/Flipcash/Utilities/URL+Links.swift index a569a8a80..f35b0d2fd 100644 --- a/Flipcash/Utilities/URL+Links.swift +++ b/Flipcash/Utilities/URL+Links.swift @@ -29,7 +29,7 @@ extension URL { static func tipcard(for userID: UserID, username: Username?) -> URL { let host = "https://flipcash.com" if let username { - return URL(string: "\(host)/\(username.value)")! + return username.profileURL } return URL(string: "\(host)/\(userID.uuidString.lowercased())")! } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index 8302053b5..ee4e2ba59 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -29,6 +29,9 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { /// A message this client cannot display — today, an encrypted one it cannot decrypt. Drawn /// as a dashed "This message can't be displayed" bubble with `hint` beneath it. case unavailable(UnavailableHint) + /// A shared profile, drawn as a card with a Share button. The card carries only the + /// handle's link; the name and picture are looked up by the view, as a link card's are. + case shareProfile(LinkCard.User) } /// The line under an unavailable message's bubble, chosen by why it can't be shown. @@ -144,7 +147,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { guard isEmojiOnly, quote == nil, linkPreview == nil else { return false } switch content { case .text: return true - case .cash, .deleted, .unavailable: return false + case .cash, .deleted, .unavailable, .shareProfile: return false } } @@ -166,7 +169,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { public var offersReactionStrip: Bool { switch content { case .deleted, .unavailable: return false - case .text, .cash: return !isUnsent + case .text, .cash, .shareProfile: return !isUnsent } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index 23a5a764f..04c2bc5ba 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -87,6 +87,10 @@ extension ChatItem { isFromSelf: message.senderID == selfUserID, senderName: counterpartName )) + case .widget(.shareProfile(let share)): + content = .shareProfile(LinkCard.User(profileOf: share.username)) + case .widget(.unrecognized): + content = .unavailable(.updateApp) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness } @@ -97,7 +101,7 @@ extension ChatItem { let isEmojiOnly: Bool switch message.content { case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted, .encrypted: isEmojiOnly = false + case .cash, .deleted, .encrypted, .widget: isEmojiOnly = false } items.append(.message(ChatMessage( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift index 527325c67..50908edf1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift @@ -51,6 +51,9 @@ public struct ChatQuote: Hashable, Sendable, Codable { /// Copy for an original the client cannot show. public static let unavailableSnippet = "Original message unavailable" + /// The line a quoted shared-profile widget shows: it has no text of its own. + public static let sharedProfileSnippet = "Shared a profile" + /// Copy for an original that has since been deleted. public static let deletedSnippet = "This message was deleted" diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift index e9bcf5f2a..e8e4f7448 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkCard.swift @@ -287,6 +287,12 @@ extension LinkCard { self.length = range.length } + /// The card for `username`'s profile link, standing on its own rather than over a span of + /// message text. What a shared-profile widget looks its person up with. + public init(profileOf username: Username) { + self.init(url: username.profileURL, identity: .username(username), range: NSRange(location: 0, length: 0)) + } + /// The `@handle` the link itself names, or nil for an id link. What a card with no account /// behind it shows as the name, since the link is all it has to go on. public var linkedHandle: String? { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 9bc90f1fd..30861a627 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -53,6 +53,17 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// is the wire `EncryptedContent.Scheme` raw value, kept as `Int` so this model doesn't depend /// on the generated proto enum. A decrypted message carries `.text` instead. case encrypted(scheme: Int, nonce: Data, ciphertext: Data) + /// A widget: structured content the sender's client drew as a card rather than text. + case widget(Widget) + } + + /// The widget a `.widget` message carries. Sent in the clear only: the E2EE allow-list is text, + /// media and reply, so a widget decrypted out of `EncryptedContent` is still unsupported. + public enum Widget: Hashable, Sendable { + case shareProfile(ShareProfileWidget) + /// A variant this client doesn't recognize, or one missing what it needs (a malformed + /// username). Drawn as an unsupported message. + case unrecognized } /// The `EncryptedContent` a message arrived as, kept beside its decrypted text. @@ -299,10 +310,14 @@ extension ConversationMessage { ) self.cashAction = nil repliedTo = nil - // `.media`/`.system`/`.widget` are dropped by design: the message is not stored and not - // shown. A widget carries no fallback text, so a client that can't draw one renders - // nothing rather than a broken bubble. - case .media, .system, .widget, .none: + case .widget(let widgetContent): + // Kept even when the variant is unknown: a widget has no fallback text, so the contract + // is that a client which can't draw one shows the message as unsupported. + self.content = .widget(widgetContent.shareProfile.map(Widget.shareProfile) ?? .unrecognized) + self.cashAction = nil + repliedTo = nil + // `.media`/`.system` are dropped by design: the message is not stored and not shown. + case .media, .system, .none: return nil } @@ -351,7 +366,7 @@ extension ConversationMessage.Content { $0.ciphertext = ciphertext }) } - case .cash, .deleted: + case .cash, .deleted, .widget: throw ConversationMessageContentEncodingError.unsupported(self) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 70d4438ea..a91da0ce7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -99,6 +99,12 @@ extension MessageCapability { // a payment record. Someone else's payment is still reportable — a payment is a thing // a person did to you. return message.isFromSelf(selfUserID) ? [.reply] : [.reply, .report] + case .widget(.unrecognized): + // Drawn as an unsupported message: nothing this client can act on. + return [] + case .widget(.shareProfile): + // Like cash: no text to copy or edit, and reply is what the quote panel supports. + return message.isFromSelf(selfUserID) ? [.reply] : [.reply, .report] case .text: break } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Username.swift b/FlipcashCore/Sources/FlipcashCore/Models/Username.swift index 0d59bb2e7..b0aab76f1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Username.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Username.swift @@ -33,6 +33,12 @@ public struct Username: Codable, Equatable, Hashable, Sendable { /// server or building a link wants ``value``. public var handle: String { "@\(value)" } + /// The public page that opens this handle's tip card, on the apex host. The one place the + /// handle form of the link is built; `URL.tipcard(for:username:)` in the app delegates here. + public var profileURL: URL { + URL(string: "https://flipcash.com/\(value)")! + } + // Mirrors the `validate.rules.string.pattern` on `common.v1.Username`; // keep the two in step when the contract moves. private nonisolated(unsafe) static let pattern = /^[a-z0-9_]{2,15}$/ diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 2882f80cc..6d8562487 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -94,7 +94,7 @@ public enum NotificationPayload { switch message.content { case .text(let text): return text - case .cash, .deleted, .encrypted: + case .cash, .deleted, .encrypted, .widget: return nil } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index d6f051cea..29e771e46 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -548,6 +548,13 @@ nonisolated extension Database { encryptedScheme = scheme encryptedNonce = nonce encryptedCiphertext = ciphertext + case .widget(let widget): + // The username rides in `text`; a widget with none is one this client can't draw. + kind = 4 + switch widget { + case .shareProfile(let share): text = share.username.value + case .unrecognized: text = nil + } } let cashAction: Int? = switch message.cashAction { @@ -746,6 +753,10 @@ nonisolated extension Database { return nil } content = .encrypted(scheme: scheme, nonce: nonce, ciphertext: ciphertext) + case 4: + content = .widget( + row[m.text].flatMap(Username.init).map { .shareProfile(ShareProfileWidget(username: $0)) } ?? .unrecognized + ) default: return nil } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index d1d35bba8..7c0ae939f 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -197,7 +197,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 43 + public static let schemaVersion = 44 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 74e359ecb..36a619624 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -722,14 +722,31 @@ struct ConversationMessageContentEncodingTests { #expect(Flipcash_Messaging_V1_WidgetContent().shareProfile == nil) } - @Test("A widget message is dropped like other content this client can't draw") - func widgetMessageIsDropped() { + @Test("A share-profile widget message is kept as a widget") + func widgetMessageIsKept() throws { let proto = Flipcash_Messaging_V1_Message.with { $0.messageID = .with { $0.value = 9 } $0.content = [.with { $0.widget = .with { $0.shareProfile = .with { $0.username = .with { $0.value = "alice" } } } }] } - #expect(ConversationMessage(proto) == nil) + let message = try #require(ConversationMessage(proto)) + #expect(message.content == .widget(.shareProfile(ShareProfileWidget(username: try #require(Username("alice")))))) + } + + @Test("A widget of an unknown variant is kept as unrecognized, not dropped") + func unknownWidgetIsUnrecognized() throws { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 9 } + $0.content = [.with { $0.widget = .init() }] + } + let message = try #require(ConversationMessage(proto)) + #expect(message.content == .widget(.unrecognized)) + } + + @Test("A widget can't be sent through the content encoder") + func widgetIsNotEncodable() throws { + let widget = ConversationMessage.Content.widget(.shareProfile(ShareProfileWidget(username: try #require(Username("alice"))))) + #expect(throws: ConversationMessageContentEncodingError.self) { try widget.asProto() } } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift index 33d7fa008..5ba21c365 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift @@ -32,6 +32,7 @@ struct ConversationStoreMutationTests { case .deleted: "" case .cash: "" case .encrypted: "" + case .widget: "" } } } diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 62697bef7..f8dc16949 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -103,11 +103,31 @@ struct DatabaseConversationsTests { switch loadedMessage.content { case .cash(let loadedExchanged): #expect(loadedExchanged.nativeAmount.value == amount) - case .text, .deleted, .encrypted: + case .text, .deleted, .encrypted, .widget: Issue.record("Expected cash message content") } } + @Test("Widget messages round-trip, and a widget with no username reads back as unrecognized") + func widgetMessageRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let alice = try #require(Username("alice")) + func widget(_ n: UInt64, _ content: ConversationMessage.Widget) -> ConversationMessage { + ConversationMessage(id: MessageID(value: n), senderID: UUID(), content: .widget(content), + date: Date(timeIntervalSince1970: TimeInterval(n)), unreadSeq: n) + } + try database.upsertConversationMessages( + [widget(1, .shareProfile(ShareProfileWidget(username: alice))), widget(2, .unrecognized)], + conversationID: id + ) + let loaded = try database.getConversationMessages(conversationID: id) + #expect(loaded.map(\.content) == [ + .widget(.shareProfile(ShareProfileWidget(username: alice))), .widget(.unrecognized), + ]) + } + @Test("Cash message tip action round-trips", arguments: [CashAction.sent, .tipped]) func cashMessageActionRoundTrip(_ action: CashAction) throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 33d909a0b..b238004c5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -157,7 +157,7 @@ public final class ChatBubbleView: UIView { let hint: String? switch message.content { case .unavailable(let unavailableHint): hint = unavailableHint.text - case .text, .cash, .deleted: hint = nil + case .text, .cash, .deleted, .shareProfile: hint = nil } hintLabel.text = hint hintLabel.isHidden = hint == nil @@ -215,14 +215,14 @@ public final class ChatBubbleView: UIView { case .text: message.isEdited case .deleted: false case .cash: false - case .unavailable: false + case .unavailable, .shareProfile: false } } private static func isUnavailable(_ message: ChatMessage) -> Bool { switch message.content { case .unavailable: true - case .text, .cash, .deleted: false + case .text, .cash, .deleted, .shareProfile: false } } @@ -265,7 +265,7 @@ public final class ChatBubbleView: UIView { isPlaceholder = true case .unavailable: return unavailableText() - case .cash: + case .cash, .shareProfile: return nil } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index afb8fdab8..ff7e237c8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -47,6 +47,8 @@ extension ChatItem { ChatMessageCell.reuseIdentifier case .cash: ChatCashCardCell.reuseIdentifier + case .shareProfile: + ChatShareProfileCell.reuseIdentifier } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 36942512b..d2f23bba2 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -162,6 +162,11 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onOpenURL = newValue } } + public var onShareProfile: ((LinkCard.User) -> Void)? { + get { transcript.onShareProfile } + set { transcript.onShareProfile = newValue } + } + public var onMentionTap: ((Username) -> Void)? { get { transcript.onMentionTap } set { transcript.onMentionTap = newValue } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift new file mode 100644 index 000000000..4ddbac51c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift @@ -0,0 +1,273 @@ +// +// ChatShareProfileCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import SwiftUI +import FlipcashCore + +/// A recycled cell for a shared-profile widget (node 10588:1979): a bubble-chrome card with the +/// person's avatar, name and handle above a full-width Share button. +/// +/// The widget carries only a handle, so the card asks its ``LinkCardSource`` for the person the way +/// a person link card does, and draws the handle alone until the answer lands. The card's width is +/// supplied by the owner, as the other card cells take theirs. +public final class ChatShareProfileCell: ChatColumnCell { + + public static let reuseIdentifier = "ChatShareProfileCell" + + /// The designed card width; a narrower transcript caps it. + static let designedWidth: CGFloat = 290 + + /// Where the card looks the person up. Set before ``configure(with:maxWidth:authorImageData:)``, + /// which is where the card subscribes. + weak var linkCardSource: (any LinkCardSource)? + + /// Called when the Share button is tapped, with the card whose handle is being shared. + var onShare: ((LinkCard.User) -> Void)? + + private let card = BubbleBackgroundView() + private let reactionRow = ReactionPillRowView() + private let content: any UIView & UIContentView + private var cardWidthConstraint: NSLayoutConstraint! + private var reactionRowWidthConstraint: NSLayoutConstraint! + private var subscription: Task? + private var shown: LinkCard.User? + + public override init(frame: CGRect) { + // Made with the content type `draw` sets later: a hosting content view traps when handed a + // configuration of a different content type. + content = UIHostingConfiguration { + ShareProfileWidgetView(username: nil, state: nil, onShare: {}) + } + .margins(.all, 0) + .makeContentView() + super.init(frame: frame) + + card.translatesAutoresizingMaskIntoConstraints = false + content.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(content) + + installColumn(content: card, accessory: reactionRow) + reactionRow.onToggle = { [weak self] emoji in self?.onReactionTap?(emoji) } + reactionRow.onLongPress = { [weak self] emoji in self?.onReactionLongPress?(emoji) } + reactionRow.onAdd = { [weak self] in self?.onReactionAdd?() } + + cardWidthConstraint = card.widthAnchor.constraint(equalToConstant: Self.designedWidth) + reactionRowWidthConstraint = reactionRow.widthAnchor.constraint(equalToConstant: Self.designedWidth) + NSLayoutConstraint.activate([ + cardWidthConstraint, + reactionRowWidthConstraint, + content.topAnchor.constraint(equalTo: card.topAnchor), + content.bottomAnchor.constraint(equalTo: card.bottomAnchor), + content.leadingAnchor.constraint(equalTo: card.leadingAnchor), + content.trailingAnchor.constraint(equalTo: card.trailingAnchor), + ]) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Fired when the viewer taps a reaction pill — the argument is the toggled emoji. + var onReactionTap: ((String) -> Void)? + /// Fired on a long-press of a reaction pill, to open the reactors sheet scoped to that emoji. + var onReactionLongPress: ((String) -> Void)? + /// Fired when the trailing "+" is tapped, to open the picker. + var onReactionAdd: (() -> Void)? + + /// Scopes the row's touch target to the card, not the full-width cell. + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + card.convert(card.bounds, to: self).contains(point) || reactionRow.convert(reactionRow.bounds, to: self).contains(point) + } + + public override func prepareForReuse() { + super.prepareForReuse() + subscription?.cancel() + subscription = nil + shown = nil + reactionRow.prepareForReuse() + } + + /// - Parameter maxWidth: the widest the card may be; the designed width caps it from above. + public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { + guard case .shareProfile(let profile) = message.content else { return } + let width = min(Self.designedWidth, maxWidth) + cardWidthConstraint.constant = width + reactionRowWidthConstraint.constant = width + + card.apply( + fill: UIColor.white.withAlphaComponent(0.02), + radii: BubbleBackgroundView.radii( + isFromSelf: message.sender == .me, + groupedAbove: message.joinsBubbleAbove, + groupedBelow: message.joinsBubbleBelow + ), + identity: message.id + ) + reactionRow.layoutWidth = width + reactionRow.hugsTrailingEdge = message.sender == .me + reactionRow.configure(pills: message.reactions, canReact: message.canReact) + updateColumn(for: message, authorImageData: authorImageData) + + // A reconfigure in place (a grouping or reaction change) keeps the subscription it has. + guard shown != profile else { return } + shown = profile + subscription?.cancel() + subscription = nil + + let source = linkCardSource + let known = source?.known(.user(profile)) + draw(profile, state: Self.userState(known)) + + guard let source else { return } + // Subscribed here rather than inside the task: an answer landing before the task first runs + // would be yielded to nobody. + let states = source.states(for: .user(profile)) + subscription = Task { [weak self] in + for await state in states { + guard let self, !Task.isCancelled else { return } + draw(profile, state: Self.userState(state)) + } + } + } + + private static func userState(_ state: LinkCard.State?) -> LinkCard.User.State? { + switch state { + case .user(let user): user + case .cash, .token, .group, nil: nil + } + } + + private func draw(_ profile: LinkCard.User, state: LinkCard.User.State?) { + content.configuration = UIHostingConfiguration { + ShareProfileWidgetView(username: profile.linkedHandle, state: state) { [weak self] in + self?.onShare?(profile) + } + } + .margins(.all, 0) + } +} + +extension ChatShareProfileCell: BubbleCarrying { + /// The card, not the cell: the cell spans the full row. + var liftPreviewView: UIView { card } + var liftPreviewMaskingPath: UIBezierPath? { card.maskingPath } + + func flashAttention(startedAt start: CFTimeInterval) { card.flashAttention(startedAt: start) } +} + +/// The widget's body: avatar, name and handle stacked and centered above a full-width Share button. +/// +/// Until the lookup answers, or when it finds nobody, the handle stands in as the name and the +/// handle line is dropped, so the card never shows a blank. +struct ShareProfileWidgetView: View { + + /// The `@handle` the widget names. + let username: String? + /// The lookup's answer; nil while it is out. + let state: LinkCard.User.State? + let onShare: () -> Void + + /// Named so Android can copy them one for one (node 10588:1979). + enum Layout { + static let padding: CGFloat = 12 + /// Between the identity block and the button. + static let sectionGap: CGFloat = 18 + /// Between the avatar and the name. + static let avatarGap: CGFloat = 12 + static let avatar: CGFloat = 64 + /// Between the name and the handle. + static let lineGap: CGFloat = 2 + static let handleOpacity: Double = 0.5 + static let iconGap: CGFloat = 4 + } + + enum Copy { + static let share = "Share" + } + + var body: some View { + VStack(spacing: Layout.sectionGap) { + VStack(spacing: Layout.avatarGap) { + avatarView + VStack(spacing: Layout.lineGap) { + Text(name) + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .lineLimit(2) + if let handle { + Text(handle) + .font(.appTextCaption) + .foregroundStyle(Color.textMain) + .opacity(Layout.handleOpacity) + .lineLimit(1) + } + } + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + + Button(action: onShare) { + HStack(spacing: Layout.iconGap) { + Image(systemName: "square.and.arrow.up") + Text(Copy.share) + } + } + .buttonStyle(.filledCompact) + } + .padding(Layout.padding) + .frame(maxWidth: .infinity) + } + + private var resolved: LinkCard.User.Resolved? { + if case .resolved(let user)? = state { user } else { nil } + } + + private var name: String { resolved?.displayName ?? username ?? "" } + + private var handle: String? { resolved?.handle } + + private var avatarView: some View { + ContactAvatarView( + id: resolved?.avatarID ?? username ?? "", + displayName: resolved?.displayName ?? "", + imageData: resolved?.imageData, + blurhash: resolved?.blurHash, + size: Layout.avatar + ) + .accessibilityHidden(true) + } +} + +#if DEBUG +private func sampleUser(name: String, handle: String?) -> LinkCard.User.State { + .resolved(LinkCard.User.Resolved( + userID: UUID(), + isOwn: false, + displayName: name, + handle: handle, + joined: nil, + imageData: nil, + blurHash: nil + )) +} + +#Preview("Share profile widget") { + VStack(spacing: 16) { + ShareProfileWidgetView(username: "@brad_burnham_2", state: sampleUser(name: "Brad Burnham", handle: "@brad_burnham_2"), onShare: {}) + ShareProfileWidgetView(username: "@brad_burnham_2", state: nil, onShare: {}) + ShareProfileWidgetView(username: "@brad_burnham_2", state: .notFound, onShare: {}) + } + .frame(width: 290) + .background(Color.white.opacity(0.02)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.backgroundMain) +} +#endif +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index a73893055..e0d4fa9ca 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -51,6 +51,8 @@ public final class ChatViewController: UICollectionViewController { /// Called when the user taps a URL in a text bubble; the owner opens it. public var onOpenURL: ((URL) -> Void)? + /// Fired when a shared-profile widget's Share button is tapped. + public var onShareProfile: ((LinkCard.User) -> Void)? /// Called when the user taps an `@handle` in a text bubble; the owner finds who it names. public var onMentionTap: ((Username) -> Void)? @@ -307,6 +309,7 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatMessageCell.self, forCellWithReuseIdentifier: ChatMessageCell.reuseIdentifier) collectionView.register(ChatLinkMessageCell.self, forCellWithReuseIdentifier: ChatLinkMessageCell.reuseIdentifier) collectionView.register(ChatCashCardCell.self, forCellWithReuseIdentifier: ChatCashCardCell.reuseIdentifier) + collectionView.register(ChatShareProfileCell.self, forCellWithReuseIdentifier: ChatShareProfileCell.reuseIdentifier) collectionView.register(ChatDateSeparatorCell.self, forCellWithReuseIdentifier: ChatDateSeparatorCell.reuseIdentifier) collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) collectionView.register(ChatEncryptionMarkerCell.self, forCellWithReuseIdentifier: ChatEncryptionMarkerCell.reuseIdentifier) @@ -646,6 +649,14 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } + case let cell as ChatShareProfileCell: + cell.linkCardSource = linkCardSource + cell.onShare = { [weak self] card in self?.onShareProfile?(card) } + let cardWidth = available - ChatColumnCell.rowInset * 2 + cell.configure(with: message, maxWidth: cardWidth, authorImageData: authorImageData) + cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } + cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } + cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } default: assertionFailure("Unhandled chat cell class for message row") } @@ -741,7 +752,7 @@ public final class ChatViewController: UICollectionViewController { let cell = collectionView.cellForItem(at: indexPath) as? BubbleCarrying else { return nil } switch message.content { case .text: break - case .cash, .deleted, .unavailable: return nil + case .cash, .deleted, .unavailable, .shareProfile: return nil } let bubble = cell.liftPreviewView guard bubble.bounds.contains(bubble.convert(point, from: collectionView)) else { return nil } diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index b253a3537..956e204c4 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -84,6 +84,9 @@ private struct NotificationMessageRow: View { NotificationTextBubble(text: ChatMessage.unavailableCopy, isFromSelf: message.sender == .me) case .cash(let cash): NotificationCashCard(cash: cash, isFromSelf: message.sender == .me) + case .shareProfile(let profile): + // The extension has no profile lookup; the handle is what the widget names. + NotificationTextBubble(text: profile.linkedHandle ?? "Shared a profile", isFromSelf: message.sender == .me) } if message.sender == .other { Spacer(minLength: 44) } } From 24829a591c6b629d259b46d0b81250935de5c09e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:15:26 -0400 Subject: [PATCH 2/4] feat(chat): draw the viewer's own share-profile widget from the session profile A widget naming the signed-in user's handle now skips the link-card lookup and uses the session profile's name and avatar. Widgets are server-sent, so they offer Reply only (no Report) and the chat-list preview no longer names a sender. Adds a rendering test for the card at the designed width. --- .../Controllers/ConversationController.swift | 5 +- .../ChatScreenRepresentable.swift | 3 + .../Conversation/ConversationScreen.swift | 25 ++++++ .../Conversation/MessageCapability.swift | 6 +- .../MessageCapabilityTests.swift | 12 +++ .../Chat/ShareProfileWidgetRenderTests.swift | 90 +++++++++++++++++++ .../Chat/ChatScreenViewController.swift | 5 ++ .../Chat/ChatShareProfileCell.swift | 27 +++++- .../FlipcashUI/Chat/ChatViewController.swift | 15 ++++ 9 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 FlipcashTests/Chat/ShareProfileWidgetRenderTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 09a2b9aea..14c344536 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1345,9 +1345,8 @@ final class ConversationController { return nil case .widget(.shareProfile): - if isFromSelf { return "You shared a profile" } - guard let senderName else { return "Shared a profile" } - return "\(senderName) shared a profile" + // The server sends it; no user is the sender to name. + return "Shared a profile" case .widget(.unrecognized): return nil diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index b101346fd..31e0e2606 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -34,6 +34,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let onMentionTap: (Username) -> Void /// Fired when the Share button on a shared-profile widget is tapped. let onShareProfile: (LinkCard.User) -> Void + let ownProfile: OwnProfileCard? /// Fired when the user taps the card drawn in place of a link, with the whole card and the /// stable id of the message it came on. The owner decides where it lands, which differs by kind. let onLinkCardTap: (LinkCard, String) -> Void @@ -137,6 +138,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap screen.onShareProfile = onShareProfile + screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource screen.onContactAction = onContactAction @@ -181,6 +183,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.onOpenURL = onOpenURL screen.onMentionTap = onMentionTap screen.onShareProfile = onShareProfile + screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource screen.onContactAction = onContactAction diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index e2bee9757..f8d585fe9 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -482,6 +482,7 @@ struct ConversationScreen: View { onOpenURL: openLink, onMentionTap: openMention, onShareProfile: shareProfile, + ownProfile: ownProfile, onLinkCardTap: openLinkCard, linkCardSource: sessionContainer.linkCardFeed, onContactAction: openContactCard, @@ -704,6 +705,11 @@ struct ConversationScreen: View { picture: groupConversation?.picture ) } + // Fetch the signed-in user's own avatar for a shared-profile widget that names them. + .task(id: sessionContainer.session.profile?.profilePicture?.thumbnailBlobID) { + guard let picture = sessionContainer.session.profile?.profilePicture else { return } + await sessionContainer.profileAvatars.load(userID: sessionContainer.session.userID, picture: picture) + } // Fetch the tip counterpart's avatar for the title and profile card. .task(id: tipCounterpart?.userID) { await sessionContainer.profileAvatars.load( @@ -1284,6 +1290,25 @@ struct ConversationScreen: View { ShareSheet.present(activityItem: item) { _ in } } + /// The session profile as a card, for a widget naming the viewer's own handle. Read from `body`, + /// so a picture landing redraws it. Nil until the profile has a claimed handle. + private var ownProfile: OwnProfileCard? { + guard let profile = sessionContainer.session.profile, let username = profile.username else { return nil } + let userID = sessionContainer.session.userID + return OwnProfileCard( + username: username, + resolved: LinkCard.User.Resolved( + userID: userID, + isOwn: true, + displayName: profile.displayName ?? "", + handle: username.handle, + joined: nil, + imageData: sessionContainer.profileAvatars.data(for: userID), + blurHash: profile.profilePicture?.thumbnailBlurhash + ) + ) + } + /// Looks up a tapped `@handle` and opens whoever it names. Resolved on tap rather than as the /// message maps: a transcript full of handles would otherwise cost a lookup per handle for /// people nobody taps. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index a91da0ce7..c28f51634 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -103,8 +103,10 @@ extension MessageCapability { // Drawn as an unsupported message: nothing this client can act on. return [] case .widget(.shareProfile): - // Like cash: no text to copy or edit, and reply is what the quote panel supports. - return message.isFromSelf(selfUserID) ? [.reply] : [.reply, .report] + // Server-authored, like cash: no text to copy or edit, and no user sender to report, + // so the only capability is Reply, which the quote panel supports. Sharing is the + // card's own button, not a menu action. + return [.reply] case .text: break } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index 6e1aa9b2a..f82057708 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -81,6 +81,18 @@ struct MessageCapabilityTests { ) } + @Test("A share-profile widget is server-sent: Reply only, never Report, whoever the sender id says") + func shareProfileWidgetOffersReplyOnly() throws { + let widget = ConversationMessage.Content.widget(.shareProfile(ShareProfileWidget(username: try #require(Username("alice"))))) + for sender in [me, them] { + let message = ConversationMessage( + id: MessageID(value: 4), senderID: sender, content: widget, + date: now, unreadSeq: 1, eventSequence: 2 + ) + #expect(resolve(message) == [.reply]) + } + } + // MARK: - Windows - private func windows(edit: TimeInterval?, delete: TimeInterval?) -> MessagePolicy { diff --git a/FlipcashTests/Chat/ShareProfileWidgetRenderTests.swift b/FlipcashTests/Chat/ShareProfileWidgetRenderTests.swift new file mode 100644 index 000000000..3cfdf3a70 --- /dev/null +++ b/FlipcashTests/Chat/ShareProfileWidgetRenderTests.swift @@ -0,0 +1,90 @@ +// +// ShareProfileWidgetRenderTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import SwiftUI +import FlipcashCore +@testable import FlipcashUI + +/// The shared-profile widget as the transcript renders it (node 10588:1979): the real cell, in a +/// window, at the designed card width. +@MainActor +@Suite("Share profile widget rendering") +struct ShareProfileWidgetRenderTests { + + private struct Rendered { + let image: UIImage + let card: CGSize + } + + private func render(ownDisplayName: String?) throws -> Rendered { + let username = try #require(Username("brad_burnham_2")) + let cell = ChatShareProfileCell(frame: CGRect(x: 0, y: 0, width: 390, height: 207)) + cell.ownProfile = ownDisplayName.map { + OwnProfileCard(username: username, resolved: LinkCard.User.Resolved( + userID: UUID(), isOwn: true, displayName: $0, handle: username.handle, + joined: nil, imageData: nil, blurHash: nil + )) + } + cell.configure( + with: ChatMessage(id: "w", content: .shareProfile(LinkCard.User(profileOf: username)), sender: .other), + maxWidth: 390 + ) + + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + let window = scene.map(UIWindow.init(windowScene:)) ?? UIWindow() + window.frame = cell.frame + window.overrideUserInterfaceStyle = .dark + let root = UIViewController() + window.rootViewController = root + window.makeKeyAndVisible() + // The window sits under the device's status bar; a cell in the transcript would not. + let inset = window.safeAreaInsets + root.additionalSafeAreaInsets = UIEdgeInsets(top: -inset.top, left: -inset.left, bottom: -inset.bottom, right: -inset.right) + root.view.backgroundColor = UIColor(Color.backgroundMain) + root.view.addSubview(cell) + cell.frame = window.bounds + cell.layoutIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.3)) + cell.layoutIfNeeded() + + let format = UIGraphicsImageRendererFormat() + format.scale = 3 + let image = UIGraphicsImageRenderer(size: cell.bounds.size, format: format).image { _ in + root.view.drawHierarchy(in: root.view.bounds, afterScreenUpdates: true) + } + let card = try #require(Self.firstSubview(of: cell, named: "BubbleBackgroundView")) + window.isHidden = true + return Rendered(image: image, card: card.frame.size) + } + + private static func firstSubview(of view: UIView, named name: String) -> UIView? { + if String(describing: type(of: view)) == name { return view } + return view.subviews.lazy.compactMap { firstSubview(of: $0, named: name) }.first + } + + @Test("The card is the designed 290pt wide and about as tall as the design's 207pt") + func designedSize() throws { + let rendered = try render(ownDisplayName: "Brad Burnham") + #expect(rendered.card.width == 290) + // 12 + 64 + 12 + name + 2 + handle + 18 + 44 + 12, with the fonts' own line heights. + #expect((195...215).contains(rendered.card.height)) + #expect(rendered.image.size.width == 390) + + if let path = ProcessInfo.processInfo.environment["SHARE_PROFILE_WIDGET_PNG"], + let data = rendered.image.pngData() { + try data.write(to: URL(fileURLWithPath: path)) + } + } + + @Test("Without a session profile the card still lays out at the same width") + func withoutOwnProfile() throws { + let rendered = try render(ownDisplayName: nil) + #expect(rendered.card.width == 290) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index d2f23bba2..6be732a6b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -162,6 +162,11 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onOpenURL = newValue } } + public var ownProfile: OwnProfileCard? { + get { transcript.ownProfile } + set { transcript.ownProfile = newValue } + } + public var onShareProfile: ((LinkCard.User) -> Void)? { get { transcript.onShareProfile } set { transcript.onShareProfile = newValue } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift index 4ddbac51c..7f725e4dd 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift @@ -10,6 +10,18 @@ import UIKit import SwiftUI import FlipcashCore +/// The signed-in user's own profile card, taken from the session so a widget naming them needs no +/// lookup. The widget's username decides whose card it is; this only answers for the viewer's own. +public struct OwnProfileCard: Equatable, Sendable { + public let username: Username + public let resolved: LinkCard.User.Resolved + + public init(username: Username, resolved: LinkCard.User.Resolved) { + self.username = username + self.resolved = resolved + } +} + /// A recycled cell for a shared-profile widget (node 10588:1979): a bubble-chrome card with the /// person's avatar, name and handle above a full-width Share button. /// @@ -37,6 +49,11 @@ public final class ChatShareProfileCell: ChatColumnCell { private var reactionRowWidthConstraint: NSLayoutConstraint! private var subscription: Task? private var shown: LinkCard.User? + private var shownOwn: OwnProfileCard? + + /// The viewer's own profile: a widget naming the same handle is drawn from it, with no lookup. + /// Set before ``configure(with:maxWidth:authorImageData:)``. + var ownProfile: OwnProfileCard? public override init(frame: CGRect) { // Made with the content type `draw` sets later: a hosting content view traps when handed a @@ -89,6 +106,7 @@ public final class ChatShareProfileCell: ChatColumnCell { subscription?.cancel() subscription = nil shown = nil + shownOwn = nil reactionRow.prepareForReuse() } @@ -114,11 +132,18 @@ public final class ChatShareProfileCell: ChatColumnCell { updateColumn(for: message, authorImageData: authorImageData) // A reconfigure in place (a grouping or reaction change) keeps the subscription it has. - guard shown != profile else { return } + guard shown != profile || shownOwn != ownProfile else { return } shown = profile + shownOwn = ownProfile subscription?.cancel() subscription = nil + // The viewer's own handle is answered from the session profile: no lookup, no loading state. + if let own = ownProfile, case .username(let username) = profile.identity, username == own.username { + draw(profile, state: .resolved(own.resolved)) + return + } + let source = linkCardSource let known = source?.known(.user(profile)) draw(profile, state: Self.userState(known)) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index e0d4fa9ca..8ddd794bb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -54,6 +54,20 @@ public final class ChatViewController: UICollectionViewController { /// Fired when a shared-profile widget's Share button is tapped. public var onShareProfile: ((LinkCard.User) -> Void)? + /// The viewer's own profile, which a shared-profile widget naming them draws from directly. Rows + /// on screen pick a change up without a diff. + public var ownProfile: OwnProfileCard? { + didSet { + guard ownProfile != oldValue, isViewLoaded else { return } + for cell in collectionView.visibleCells where cell is ChatShareProfileCell { + guard let indexPath = collectionView.indexPath(for: cell), + items.indices.contains(indexPath.item), + case .message(let message) = items[indexPath.item] else { continue } + configure(cell, with: message) + } + } + } + /// Called when the user taps an `@handle` in a text bubble; the owner finds who it names. public var onMentionTap: ((Username) -> Void)? @@ -651,6 +665,7 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } case let cell as ChatShareProfileCell: cell.linkCardSource = linkCardSource + cell.ownProfile = ownProfile cell.onShare = { [weak self] card in self?.onShareProfile?(card) } let cardWidth = available - ChatColumnCell.rowInset * 2 cell.configure(with: message, maxWidth: cardWidth, authorImageData: authorImageData) From 4344405878e947baa1444bde8e368cfe5efbf615 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:39:12 -0400 Subject: [PATCH 3/4] feat(chat): gate a widget's Reply and reactions on the chat's speaker rule A share-profile widget has no author, so its Reply and reactions now follow the same conversationGate speaker verdict as the composer: a never rule, an unmet balance or an unmet staff requirement withholds both. Report stays off. The screen hands ConversationLoadCoordinator a canSpeak closure evaluated in the observation scope; MessageCapability.resolve and ChatItem.from take it. --- .../Conversation/ChatItem+Conversation.swift | 10 +- .../ConversationLoadCoordinator.swift | 12 +- .../Conversation/ConversationScreen.swift | 7 ++ .../Conversation/MessageCapability.swift | 16 ++- .../MessageCapabilityTests.swift | 22 +++- .../Chat/ShareProfileWidgetGatingTests.swift | 110 ++++++++++++++++++ 6 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 FlipcashTests/Chat/ShareProfileWidgetGatingTests.swift diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 3bee725a3..7b636cd0e 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -82,6 +82,9 @@ extension ChatItem { /// Whether the viewer may add or remove a reaction — false for someone previewing a group /// they have not joined (see `ChatMessage.canReact`). canReact: Bool = true, + /// Whether the chat's speaker rule lets the viewer speak. A widget has no author to gate on, + /// so its reactions follow this in addition to `canReact`. + canSpeak: Bool = true, /// The card, if any, for a message's detected links. Injected like the other collaborators /// so the mapper stays pure; the screen supplies classification and nothing else, and it /// defaults to no card. Takes the detected links rather than the text so the detector runs @@ -352,7 +355,7 @@ extension ChatItem { // last row, with its receipt. reactions: isLast ? message.reactionState?.pills ?? [] : [], selfReactions: message.reactionState?.selfReactions ?? [], - canReact: canReact, + canReact: canReact && (isWidget(message) ? canSpeak : true), isUnsent: isUnsent ))) } @@ -543,6 +546,11 @@ extension ChatItem { /// /// Report goes last: it is the rarest row and the only one that never appears on your own /// message, so the menu reads "yours ends with delete, theirs ends with report". + nonisolated private static func isWidget(_ message: ConversationMessage) -> Bool { + if case .widget = message.content { return true } + return false + } + nonisolated private static func orderedActions(_ capabilities: Set) -> [MessageCapability] { [.copy, .reply, .edit, .delete, .report].filter(capabilities.contains) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 023243c2e..ab0f9a0ab 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -47,6 +47,9 @@ final class ConversationLoadCoordinator { /// 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? + /// Whether the chat's speaker rule lets the viewer speak, evaluated inside the observation scope + /// so a balance or rule change re-maps. Gates Reply and reactions on a widget. + private let canSpeak: @MainActor () -> Bool /// 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 @@ -87,8 +90,10 @@ final class ConversationLoadCoordinator { controller: ConversationController, session: Session, knownAuthors: KnownAuthorDirectory, - profileCard: @escaping @MainActor () -> ChatProfileCard? + profileCard: @escaping @MainActor () -> ChatProfileCard?, + canSpeak: @escaping @MainActor () -> Bool = { true } ) { + self.canSpeak = canSpeak self.conversationID = conversationID self.controller = controller self.session = session @@ -267,6 +272,7 @@ final class ConversationLoadCoordinator { now: capabilityClock, namesAuthors: namesAuthors, canReact: conversation.map { controller.isMember(of: $0) } ?? true, + canSpeak: canSpeak(), // Only a transcript that attributes its rows has anything to resolve, so a DM never // takes a dependency on the directory and never re-maps when it reloads. knownAuthors: namesAuthors ? knownAuthors.snapshot : .empty, @@ -316,6 +322,7 @@ final class ConversationLoadCoordinator { in: inputs.conversation, as: inputs.selfUserID, isMember: inputs.isMember, + canSpeak: inputs.canSpeak, policy: inputs.policy, now: inputs.now ) @@ -327,6 +334,7 @@ final class ConversationLoadCoordinator { author: { message in message.senderID.flatMap { authors[$0] } }, namesAuthors: inputs.namesAuthors, canReact: inputs.canReact, + canSpeak: inputs.canSpeak, // Classification is pure and host-gated, and that is all mapping does with a link: the // card is the link's identity, and the card view looks it up for itself. So nothing // here touches the network, and an answer landing cannot re-diff this window. @@ -431,6 +439,8 @@ final class ConversationLoadCoordinator { /// Whether the viewer may add or remove a reaction — false for someone previewing a group /// they have not joined. True for a DM, where there is no conversation record to preview. var canReact: Bool + /// The chat's speaker rule as the viewer stands against it; see ``ConversationLoadCoordinator/canSpeak``. + var canSpeak: Bool /// Identities for senders the chat's own roster leaves out. Compared by identity — see /// ``KnownAuthorDirectory/Snapshot``. var knownAuthors: KnownAuthorDirectory.Snapshot diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index f8d585fe9..566c4bd12 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1415,6 +1415,13 @@ struct ConversationScreen: View { // must pick up the conversation the first tip creates. fallbackCounterpart: Self.cachedCounterpart(counterpartUserID, session: session) ) + }, + // The chat's speaker rule, the same `conversationGate` verdict that decides whether + // the composer is live, so a widget's Reply and reactions open and close with it. + canSpeak: { [session, ratesController, conversationController] in + guard let chat = conversationController.conversation(withID: id), chat.type == .group else { return true } + guard conversationController.isMember(of: chat) else { return false } + return conversationGate(session: session, rules: chat.rules, rates: ratesController.cachedRates).isOpen } ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index c28f51634..717f039b3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -66,16 +66,23 @@ extension MessageCapability { /// `isMember` is false for a non-member reading a group they have not joined. They get no Reply: /// the Join panel stands where the composer would, so there is nothing to reply with. /// + /// `canSpeak` is the chat's speaker rule as the viewer stands against it (`ConversationGate`'s + /// speaker verdict, with membership). It gates only the server-authored widget, whose Reply and + /// reactions have no other reason to be withheld: a person's message keeps the composer-based + /// gating above. A `never` rule, an unmet balance or an unmet staff requirement all arrive here + /// as false. + /// /// `now` is a parameter rather than `Date.now` so the result stays a function of its inputs. public static func resolve( for message: ConversationMessage, in conversation: Conversation?, as selfUserID: UserID, isMember: Bool, + canSpeak: Bool = true, policy: MessagePolicy, now: Date ) -> Set { - var capabilities = memberCapabilities(for: message, as: selfUserID, policy: policy, now: now) + var capabilities = memberCapabilities(for: message, as: selfUserID, canSpeak: canSpeak, policy: policy, now: now) if !isMember { capabilities.remove(.reply) } @@ -85,6 +92,7 @@ extension MessageCapability { private static func memberCapabilities( for message: ConversationMessage, as selfUserID: UserID, + canSpeak: Bool, policy: MessagePolicy, now: Date ) -> Set { @@ -104,9 +112,9 @@ extension MessageCapability { return [] case .widget(.shareProfile): // Server-authored, like cash: no text to copy or edit, and no user sender to report, - // so the only capability is Reply, which the quote panel supports. Sharing is the - // card's own button, not a menu action. - return [.reply] + // so the only capability is Reply, which the quote panel supports. Reply follows the + // chat's speaker rule like any message. Sharing is the card's own button, not a menu action. + return canSpeak ? [.reply] : [] case .text: break } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index f82057708..3fec13426 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -24,8 +24,8 @@ struct MessageCapabilityTests { ) } - private func resolve(_ message: ConversationMessage, isMember: Bool = true, policy: MessagePolicy = .default) -> Set { - MessageCapability.resolve(for: message, in: nil, as: me, isMember: isMember, policy: policy, now: now) + private func resolve(_ message: ConversationMessage, isMember: Bool = true, canSpeak: Bool = true, policy: MessagePolicy = .default) -> Set { + MessageCapability.resolve(for: message, in: nil, as: me, isMember: isMember, canSpeak: canSpeak, policy: policy, now: now) } @Test("My own confirmed text can be copied, edited, and deleted") @@ -93,6 +93,24 @@ struct MessageCapabilityTests { } } + @Test("A share-profile widget's Reply follows the speaker rule: none when the viewer cannot speak") + func shareProfileWidgetReplyFollowsSpeakerRule() throws { + let widget = ConversationMessage.Content.widget(.shareProfile(ShareProfileWidget(username: try #require(Username("alice"))))) + let message = ConversationMessage( + id: MessageID(value: 4), senderID: nil, content: widget, + date: now, unreadSeq: 1, eventSequence: 2 + ) + #expect(resolve(message, canSpeak: true) == [.reply]) + #expect(resolve(message, canSpeak: false) == []) + // A non-member has no composer either, whatever the speaker rule says. + #expect(resolve(message, isMember: false, canSpeak: true) == []) + } + + @Test("The speaker rule gates only widgets: a person's text keeps its composer-based menu") + func speakerRuleDoesNotGateText() { + #expect(resolve(text("hi", from: them), canSpeak: false) == [.copy, .reply, .report]) + } + // MARK: - Windows - private func windows(edit: TimeInterval?, delete: TimeInterval?) -> MessagePolicy { diff --git a/FlipcashTests/Chat/ShareProfileWidgetGatingTests.swift b/FlipcashTests/Chat/ShareProfileWidgetGatingTests.swift new file mode 100644 index 000000000..8c972cdd0 --- /dev/null +++ b/FlipcashTests/Chat/ShareProfileWidgetGatingTests.swift @@ -0,0 +1,110 @@ +// +// ShareProfileWidgetGatingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +import FlipcashUI +import FlipcashStore +@testable import Flipcash + +/// A widget's Reply and reactions follow the chat's speaker rule, evaluated by `conversationGate` +/// exactly as it is for the composer. +@MainActor +@Suite("Share-profile widget speaker gating") +struct ShareProfileWidgetGatingTests { + + private final class Holdings: ConversationGateReading { + var isStaff: Bool + var totalBalance = ExchangedFiat(nativeAmount: .usd(0), rate: Rate(fx: 1, currency: .usd)) + init(isStaff: Bool = false) { self.isStaff = isStaff } + func balance(for mint: PublicKey) -> StoredBalance? { nil } + } + + private let me = UUID() + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func widget() throws -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 1), senderID: nil, + content: .widget(.shareProfile(ShareProfileWidget(username: try #require(Username("alice"))))), + date: now, unreadSeq: 1, eventSequence: 1 + ) + } + + /// What the screen hands the coordinator: whether the viewer, a member, may speak under `rules`. + private func canSpeak(_ rules: ConversationRules, holdings: Holdings = Holdings()) -> Bool { + conversationGate(session: holdings, rules: rules, rates: [:]).isOpen + } + + private func offered(canSpeak: Bool) throws -> (actions: Set, canReact: Bool) { + let message = try widget() + let items = ChatItem.from( + [message], + selfUserID: me, + capabilities: { + MessageCapability.resolve(for: $0, in: nil, as: me, isMember: true, canSpeak: canSpeak, policy: .default, now: now) + }, + canSpeak: canSpeak + ) + for item in items { + if case .message(let row) = item { return (Set(row.actions), row.canReact) } + } + Issue.record("no message row") + return ([], false) + } + + @Test("An open chat offers Reply and reactions") + func openChat() throws { + #expect(canSpeak(ConversationRules())) + let result = try offered(canSpeak: true) + #expect(result.actions == [.reply]) + #expect(result.canReact) + } + + @Test("A never speaker rule offers neither Reply nor reactions") + func neverRule() throws { + let rules = ConversationRules(speaker: [.never]) + #expect(!canSpeak(rules, holdings: Holdings(isStaff: true))) + let result = try offered(canSpeak: canSpeak(rules)) + #expect(result.actions.isEmpty) + #expect(!result.canReact) + } + + @Test("An unmet balance requirement offers neither") + func unmetBalance() throws { + let rules = ConversationRules(speaker: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(100), mints: []))]) + #expect(!canSpeak(rules)) + let result = try offered(canSpeak: canSpeak(rules)) + #expect(result.actions.isEmpty) + #expect(!result.canReact) + } + + @Test("An unmet staff requirement offers neither, and a staff member is offered both") + func staffRule() throws { + let rules = ConversationRules(speaker: [.staff]) + #expect(!canSpeak(rules)) + #expect(canSpeak(rules, holdings: Holdings(isStaff: true))) + let result = try offered(canSpeak: canSpeak(rules)) + #expect(result.actions.isEmpty) + #expect(!result.canReact) + } + + @Test("A reply to a widget quotes it as \"Shared a profile\"") + func replyQuote() throws { + let original = try widget() + let reply = ConversationMessage( + id: MessageID(value: 2), senderID: me, content: .text("nice"), + date: now.addingTimeInterval(5), unreadSeq: 2, eventSequence: 2, repliedTo: original.id + ) + let items = ChatItem.from([original, reply], selfUserID: me, quotedMessage: { $0 == original.id ? original : nil }) + let quote = items.compactMap { item -> ChatQuote? in + if case .message(let row) = item { return row.quote } else { return nil } + }.last + #expect(quote?.snippet == "Shared a profile") + } +} From b0de3aaa74d5004eb6eb9c50e0c0e3398717d30b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:39:14 -0400 Subject: [PATCH 4/4] fix(chat): mute the share-profile widget avatar's initials to 40% ContactAvatarView takes an initialsColor (default unchanged); the widget passes textMain at 0.4, as the design draws them (node 10588:1979). --- .../Sources/FlipcashUI/Chat/ChatShareProfileCell.swift | 6 +++++- .../FlipcashUI/Views/Contacts/ContactAvatarView.swift | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift index 7f725e4dd..c0aa41db5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatShareProfileCell.swift @@ -208,6 +208,8 @@ struct ShareProfileWidgetView: View { /// Between the name and the handle. static let lineGap: CGFloat = 2 static let handleOpacity: Double = 0.5 + /// The avatar monogram's strength, muted per the design. + static let initialsOpacity: Double = 0.4 static let iconGap: CGFloat = 4 } @@ -262,7 +264,9 @@ struct ShareProfileWidgetView: View { displayName: resolved?.displayName ?? "", imageData: resolved?.imageData, blurhash: resolved?.blurHash, - size: Layout.avatar + size: Layout.avatar, + // The design mutes the monogram to 40% (node 10588:1979), unlike the app's other avatars. + initialsColor: Color.textMain.opacity(Layout.initialsOpacity) ) .accessibilityHidden(true) } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift index 1b3926a38..d91675814 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift @@ -18,13 +18,17 @@ public struct ContactAvatarView: View { public let imageData: Data? public let blurhash: String? public let size: CGFloat + /// The monogram's color. Full-strength by default; a surface whose design mutes the initials + /// passes a dimmer one. + public let initialsColor: Color - public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44) { + public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44, initialsColor: Color = .textMain) { self.id = id self.displayName = displayName self.imageData = imageData self.blurhash = blurhash self.size = size + self.initialsColor = initialsColor } public var body: some View { @@ -49,7 +53,7 @@ public struct ContactAvatarView: View { // Scales with the avatar, preserving the default (44pt) avatar's // 16pt monogram proportions at any size. .font(.default(size: size * 16 / 44, weight: .bold)) - .foregroundStyle(Color.textMain) + .foregroundStyle(initialsColor) case .placeholder: PeopleSilhouette(size: size) }