From 60d5cff0e65de5e6539245550057cec05ee92b05 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 12:17:35 -0400 Subject: [PATCH 1/3] feat(history): title boosted payments and link them to the boosted message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist the boosted reaction in a side table (schemaVersion 57) so a boost still reads as one after a cold load. Rows read 'Boosted 🔥 for Sally', 'Boost 🔥 from Sally', 'Boosted 🔥 in Chat' for a split boost, with no-name fallbacks. The detail screen gets a View Message button that opens the boosted message through the deep link's hydration path. --- .../Deep Links/DeepLinkController.swift | 2 +- .../Core/Screens/Main/Home/ActivityRow.swift | 55 +++++++++++- .../Main/Home/TransactionDetailsScreen.swift | 24 +++++- .../FlipcashCore/Models/Activity.swift | 3 +- .../Models/TransactionDetails.swift | 19 +++++ .../FlipcashStore/Database+Activities.swift | 53 +++++++++++- .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 28 +++++++ .../TransactionDetailsTests.swift | 49 +++++++++++ .../ActivityRowBoostTitleTests.swift | 84 +++++++++++++++++++ .../Database+BoostedReactionTests.swift | 68 +++++++++++++++ 11 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 FlipcashTests/ActivityRowBoostTitleTests.swift create mode 100644 FlipcashTests/Database/Database+BoostedReactionTests.swift diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 77d8f16b3..623a63a3c 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -231,7 +231,7 @@ struct DeepLinkAction { /// joined this link is the only way in — and the screen it lands on is the one that offers the join. /// /// `landing` names the message a message link points at; the transcript opens on it. - private static func routeChat( + static func routeChat( _ conversationID: ConversationID, landing: MessageLanding? = nil, origin: DeepLinkOrigin, diff --git a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift index 9e5a9e386..f3bebdca1 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift @@ -106,13 +106,66 @@ struct ActivityRow: View { ) ?? activity.title } - return Self.peerTitle( + return Self.title( kind: activity.kind, + boostedReaction: activity.boostedReaction, name: resolution.counterpartyName, + chatName: boostChatName, serverTitle: activity.title ) ?? activity.title } + /// The boosted chat's name, for a split boost only: a payment with a counterparty is named by + /// that person. Read from the local chat store, never fetched; `nil` when the viewer has left + /// the chat or it has not synced. + private var boostChatName: String? { + guard let boost = activity.boostedReaction, activity.counterparty == nil else { return nil } + let controller = sessionContainer.conversationController + return controller.conversation(withID: boost.conversationID).map(controller.displayName(for:)) + } + + /// The title of a peer payment row: the boost title when the payment boosted a reaction, + /// else ``peerTitle(kind:name:serverTitle:)``. Returns `nil` when the caller should fall back + /// to the server-rendered title. + /// + /// A boost wins over the tip verb check, and an unresolved name shows the no-name title + /// rather than the server's. + static func title( + kind: Activity.Kind, + boostedReaction: Activity.BoostedReaction?, + name: String?, + chatName: String?, + serverTitle: String + ) -> String? { + guard let boostedReaction else { + return peerTitle(kind: kind, name: name, serverTitle: serverTitle) + } + return boostTitle(kind: kind, emoji: boostedReaction.emoji, name: name, chatName: chatName) + } + + /// How a boosted payment is titled: "Boosted 🔥 for Sally" / "Boost 🔥 from Sally" once the + /// counterparty resolves, "Boosted 🔥 in Design Team" for a split boost whose chat is known, + /// and "Boosted 🔥" / "Boost 🔥 received" when there is no name to use. Returns `nil` for a + /// kind that cannot carry a boost. + static func boostTitle(kind: Activity.Kind, emoji: String, name: String?, chatName: String?) -> String? { + // Empty strings count as unresolved. No closures here: they would inherit the view's + // main-actor isolation and trap when a test calls this off the main actor. + let name = (name?.isEmpty ?? true) ? nil : name + let chatName = (chatName?.isEmpty ?? true) ? nil : chatName + + switch kind { + case .gave: + if let name { return "Boosted \(emoji) for \(name)" } + if let chatName { return "Boosted \(emoji) in \(chatName)" } + return "Boosted \(emoji)" + case .received: + if let name { return "Boost \(emoji) from \(name)" } + return "Boost \(emoji) received" + default: + return nil + } + } + /// How a peer payment is titled once its counterparty resolves — "Tipped /// " / "Tip from " for a tip, "Sent to " / "Received from /// " for a plain send. Returns `nil` for a row that is not a peer diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index f703074b4..394905366 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -70,7 +70,13 @@ struct TransactionDetailsScreen: View { receiptCard idCard - if let userID = activity.counterparty?.userID, details.canViewInChat { + switch details.chatAction { + case .viewMessage(let boost): + Button("View Message") { + viewMessage(boost) + } + .buttonStyle(.filled05) + case .viewCounterparty(let userID): let dmID = sessionContainer.conversationController.tipDMID(withUserID: userID) Button(dmID == nil ? "View Profile" : "View in Chat") { // Pushed onto the stack this screen is already @@ -83,6 +89,8 @@ struct TransactionDetailsScreen: View { router.push(DMRoute.destination(for: userID, dmID: dmID, origin: .transaction)) } .buttonStyle(.filled05) + case nil: + EmptyView() } } } @@ -297,6 +305,20 @@ struct TransactionDetailsScreen: View { } } + /// Opens the boosted message through the deep link's own hydration, so a chat the viewer has + /// not synced is fetched first and a private group they are not in lands on its profile. + private func viewMessage(_ boost: Activity.BoostedReaction) { + Task { + await DeepLinkAction.routeChat( + boost.conversationID, + landing: MessageLanding(messageID: boost.messageID, returnTo: nil), + // Not an invite: the viewer is opening a message from their own history. + origin: .activity, + in: sessionContainer + ) + } + } + private func confirmCancelAction() { guard let metadata = activity.cancellableCashLinkMetadata else { return } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift index aff1035e1..6ef67273d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Activity.swift @@ -28,8 +28,7 @@ public struct Activity: Identifiable, Sendable, Equatable, Hashable { /// withdrawals). Used to resolve an avatar and a richer name in feed rows. public let counterparty: Counterparty? - /// The reaction this payment boosted, when it was a boost. Not persisted: an activity restored - /// from the local database reports `nil` until the server's copy refreshes it. + /// The reaction this payment boosted, when it was a boost. public let boostedReaction: BoostedReaction? public var cancellableCashLinkMetadata: CashLinkMetadata? { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift index 53fd0ea5b..8747e06ef 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -132,6 +132,17 @@ public struct TransactionDetails: Sendable, Equatable, Hashable { /// Whether the counterparty can be opened from here: their DM, or their profile until it exists. public let canViewInChat: Bool + /// The chat button the screen draws, or `nil` for none. A boost opens the boosted message, even + /// when the payment had no counterparty to open. + public let chatAction: ChatAction? + + public enum ChatAction: Sendable, Equatable, Hashable { + /// Opens the message a boost was for. + case viewMessage(Activity.BoostedReaction) + /// Opens the counterparty's DM, or their profile until the DM exists. + case viewCounterparty(UserID) + } + /// The line the header actually renders. public var title: String { heading ?? kind.heading } @@ -181,6 +192,14 @@ extension TransactionDetails { // identifies one — a phone-number counterparty has no chat or profile. // The display name is not required: the profile fetches its own. self.canViewInChat = activity.counterparty?.userID != nil + // A boost wins over the counterparty: the message it was for is the more specific place. + if let boost = activity.boostedReaction { + self.chatAction = .viewMessage(boost) + } else if let userID = activity.counterparty?.userID { + self.chatAction = .viewCounterparty(userID) + } else { + self.chatAction = nil + } } /// The other side of the movement, for the kinds whose heading doesn't diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift index 427a66bb1..6cd378c0a 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Activities.swift @@ -96,12 +96,17 @@ nonisolated extension Database { s.toCurrency, s.feeNativeAmount, s.feeCurrency, - s.state AS swapState + s.state AS swapState, + + b.conversationID AS boostConversationID, + b.messageID AS boostMessageID, + b.emoji AS boostEmoji FROM activity a LEFT JOIN cashLinkMetadata c ON c.id = a.id LEFT JOIN swapMetadata s ON s.id = a.id + LEFT JOIN boostedReaction b ON b.id = a.id WHERE a.mint = ? @@ -143,12 +148,17 @@ nonisolated extension Database { s.toCurrency, s.feeNativeAmount, s.feeCurrency, - s.state AS swapState + s.state AS swapState, + + b.conversationID AS boostConversationID, + b.messageID AS boostMessageID, + b.emoji AS boostEmoji FROM activity a LEFT JOIN cashLinkMetadata c ON c.id = a.id LEFT JOIN swapMetadata s ON s.id = a.id + LEFT JOIN boostedReaction b ON b.id = a.id ORDER BY a.date DESC LIMIT ?; @@ -188,7 +198,26 @@ nonisolated extension Database { ), date: row[a.date], metadata: metadata(for: kind, row: row), - counterparty: Activity.Counterparty(userID: row[a.counterpartyUserID], phone: row[a.counterpartyPhone]) + counterparty: Activity.Counterparty(userID: row[a.counterpartyUserID], phone: row[a.counterpartyPhone]), + boostedReaction: boostedReaction(from: row) + ) + } + + /// Reads the joined `boostedReaction` columns, aliased in the SELECT to stay clear of the + /// activity and swap columns. All three are NULL for a payment that boosted nothing. + private func boostedReaction(from row: RowIterator.Element) -> Activity.BoostedReaction? { + guard + let conversationID = row[Expression("boostConversationID")], + let messageID = row[Expression("boostMessageID")], + let emoji = row[Expression("boostEmoji")] + else { + return nil + } + + return Activity.BoostedReaction( + conversationID: ConversationID(data: conversationID), + messageID: MessageID(value: messageID), + emoji: emoji ) } @@ -289,6 +318,24 @@ nonisolated extension Database { try insertSwapMetadata(id: activity.id, metadata: metadata) } } + + // A re-sync of the same activity replaces the row, so a boost the server no longer + // reports is removed rather than left behind. + let boostTable = BoostedReactionTable() + if let boost = activity.boostedReaction { + try writer.run( + boostTable.table.upsert( + boostTable.id <- activity.id, + boostTable.conversationID <- boost.conversationID.data, + boostTable.messageID <- boost.messageID.value, + boostTable.emoji <- boost.emoji, + + onConflictOf: boostTable.id, + ) + ) + } else { + try writer.run(boostTable.table.filter(boostTable.id == activity.id).delete()) + } } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 99fd53989..d2414aeb6 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,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 = 56 + public static let schemaVersion = 57 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 9a6ab4c7b..18ad12fad 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -113,6 +113,21 @@ nonisolated public struct SwapMetadataTable: Sendable { public let state = Expression ("state") } +// Side table for payments that boosted a reaction: the chat and message boosted, and the emoji. +// Joined 1:1 to `activity` by id; a row exists only for a boosted payment. A snapshot from payment +// time, so the message or chat it names may no longer exist. +nonisolated public struct BoostedReactionTable: Sendable { + public static let name = "boostedReaction" + + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let conversationID = Expression ("conversationID") + public let messageID = Expression ("messageID") + public let emoji = Expression ("emoji") +} + nonisolated public struct LimitsTable: Sendable { public static let name = "limits" @@ -494,6 +509,19 @@ nonisolated extension Database { }) } + let boostedReactionTable = BoostedReactionTable() + + try writer.transaction(.immediate) { + try writer.run(boostedReactionTable.table.create(ifNotExists: true, withoutRowid: true) { t in + t.column(boostedReactionTable.id, primaryKey: true) + t.column(boostedReactionTable.conversationID) + t.column(boostedReactionTable.messageID) + t.column(boostedReactionTable.emoji) + + t.foreignKey(boostedReactionTable.id, references: activityTable.table, activityTable.id, delete: .cascade) + }) + } + let limitsTable = LimitsTable() try writer.transaction(.immediate) { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift index 79ae3e70a..68ff5ec61 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift @@ -130,6 +130,55 @@ struct TransactionDetailsTests { #expect(details.canViewInChat == false) } + // MARK: - Boosted reaction + + private func boost() -> Activity.BoostedReaction { + Activity.BoostedReaction( + conversationID: ConversationID(data: Data(repeating: 3, count: 16)), + messageID: MessageID(value: 9), + emoji: "🔥" + ) + } + + @Test("D1: a boost with a counterparty opens the boosted message") + func boostWithCounterpartyViewsMessage() { + let boost = boost() + let details = TransactionDetails( + activity: Activity( + id: .jeffy, state: .completed, kind: .gave, title: "Sent", + exchangedFiat: Self.usd(1), date: .now, metadata: nil, + counterparty: counterparty, boostedReaction: boost + ) + ) + + #expect(details.chatAction == .viewMessage(boost)) + } + + @Test("D2: a split boost with no counterparty still opens the boosted message") + func splitBoostViewsMessage() { + let boost = boost() + let details = TransactionDetails( + activity: Activity( + id: .jeffy, state: .completed, kind: .gave, title: "Sent", + exchangedFiat: Self.usd(1), date: .now, metadata: nil, + counterparty: nil, boostedReaction: boost + ) + ) + + #expect(details.canViewInChat == false) + #expect(details.chatAction == .viewMessage(boost)) + } + + @Test("D3: without a boost the counterparty action is unchanged") + func noBoostKeepsCounterpartyAction() throws { + let withPerson = TransactionDetails(activity: activity(kind: .gave, counterparty: counterparty)) + let userID = try #require(counterparty.userID) + #expect(withPerson.chatAction == .viewCounterparty(userID)) + + let alone = TransactionDetails(activity: activity(kind: .gave)) + #expect(alone.chatAction == nil) + } + // MARK: - Actions @Test("Only an open cash link can be cancelled") diff --git a/FlipcashTests/ActivityRowBoostTitleTests.swift b/FlipcashTests/ActivityRowBoostTitleTests.swift new file mode 100644 index 000000000..efacc71e6 --- /dev/null +++ b/FlipcashTests/ActivityRowBoostTitleTests.swift @@ -0,0 +1,84 @@ +// +// ActivityRowBoostTitleTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("Boosted reaction row title") +struct ActivityRowBoostTitleTests { + + private func boost(_ emoji: String) -> Activity.BoostedReaction { + Activity.BoostedReaction( + conversationID: ConversationID(data: Data(repeating: 1, count: 16)), + messageID: MessageID(value: 7), + emoji: emoji + ) + } + + private func title( + _ kind: Activity.Kind, + emoji: String?, + name: String?, + chat: String? = nil, + server: String + ) -> String? { + ActivityRow.title( + kind: kind, + boostedReaction: emoji.map(boost), + name: name, + chatName: chat, + serverTitle: server + ) + } + + @Test("1: sent boost with a resolved name") + func sentNamed() { + #expect(title(.gave, emoji: "🔥", name: "Sally", server: "Sent to Sally") == "Boosted 🔥 for Sally") + } + + @Test("2: received boost with a resolved name") + func receivedNamed() { + #expect(title(.received, emoji: "🔥", name: "Sally", server: "Received from Sally") == "Boost 🔥 from Sally") + } + + @Test("3: split boost names the chat") + func splitWithChat() { + #expect(title(.gave, emoji: "🔥", name: nil, chat: "Design Team", server: "Sent") == "Boosted 🔥 in Design Team") + } + + @Test("4: split boost with no chat to name") + func splitWithoutChat() { + #expect(title(.gave, emoji: "🔥", name: nil, server: "Sent") == "Boosted 🔥") + #expect(title(.gave, emoji: "🔥", name: nil, chat: "", server: "Sent") == "Boosted 🔥") + } + + @Test("5: received boost with an unresolved name") + func receivedUnresolved() { + #expect(title(.received, emoji: "🔥", name: nil, server: "Received") == "Boost 🔥 received") + #expect(title(.received, emoji: "🔥", name: "", server: "Received") == "Boost 🔥 received") + } + + @Test("6: a boost wins over the tip verb, and the emoji is rendered verbatim") + func boostWinsOverTip() { + #expect(title(.gave, emoji: "👍🏽", name: "Sally", server: "Tipped Sally") == "Boosted 👍🏽 for Sally") + } + + @Test("7: a tip without a boost is unchanged") + func tipUnchanged() { + #expect(title(.gave, emoji: nil, name: "Sally", server: "Tipped Sally") == "Tipped Sally") + } + + @Test("8: a receive without a boost is unchanged") + func receiveUnchanged() { + #expect(title(.received, emoji: nil, name: "Sally", server: "Received from Sally") == "Received from Sally") + } + + @Test("A kind that cannot carry a boost falls back to the server title") + func otherKindFallsBack() { + #expect(title(.bought, emoji: "🔥", name: "Sally", server: "Purchased") == nil) + } +} diff --git a/FlipcashTests/Database/Database+BoostedReactionTests.swift b/FlipcashTests/Database/Database+BoostedReactionTests.swift new file mode 100644 index 000000000..a30319ea5 --- /dev/null +++ b/FlipcashTests/Database/Database+BoostedReactionTests.swift @@ -0,0 +1,68 @@ +// +// Database+BoostedReactionTests.swift +// FlipcashTests +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Database+BoostedReaction") +struct DatabaseBoostedReactionTests { + + private static let boost = Activity.BoostedReaction( + conversationID: ConversationID(data: Data((0..<16).map(UInt8.init))), + messageID: MessageID(value: 42), + emoji: "👍🏽" + ) + + private static func activity(boost: Activity.BoostedReaction?) -> Activity { + Activity( + id: .jeffy, + state: .completed, + kind: .gave, + title: "Sent", + exchangedFiat: .mockOne, + date: .now, + metadata: nil, + boostedReaction: boost + ) + } + + @Test("P1: a boosted payment reads back with the same chat, message and emoji") + func roundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(boost: Self.boost)]) + + let recent = try database.getRecentActivities(limit: 10) + #expect(recent.first?.boostedReaction == Self.boost) + + let perMint = try database.getActivities(mint: ExchangedFiat.mockOne.mint) + #expect(perMint.first?.boostedReaction == Self.boost) + } + + @Test("P2: a payment without a boost reads back nil") + func noBoost() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(boost: nil)]) + + #expect(try database.getRecentActivities(limit: 10).first?.boostedReaction == nil) + } + + @Test("Re-inserting an activity without its boost removes the stored boost") + func resyncDropsBoost() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + + try database.insertActivities(activities: [Self.activity(boost: Self.boost)]) + try database.insertActivities(activities: [Self.activity(boost: nil)]) + + #expect(try database.getRecentActivities(limit: 10).first?.boostedReaction == nil) + } +} From 5529c16932601f747405ef0951a932cb0150fa5e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 12:26:07 -0400 Subject: [PATCH 2/3] feat(history): show the boost on the detail header and receipt card A boost keeps the name as the large heading and puts 'You boosted' or 'Boost received' under it, winning over 'You tipped'. A split boost is headed by the stored chat's name, or by the boost line alone when the chat is not stored. The receipt card gains Reaction and Chat rows after Date, read from the persisted boost and the local chat store, with no fetch. --- .../Main/Home/TransactionDetailsScreen.swift | 15 +++ .../Models/TransactionDetails.swift | 33 ++++++- .../TransactionDetailsTests.swift | 96 +++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index 394905366..2ae6cca69 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -54,9 +54,18 @@ struct TransactionDetailsScreen: View { counterpartyName: resolution.counterpartyName, fromTokenName: fromTokenName, toTokenName: toTokenName, + boostChatName: boostChatName, ) } + /// The boosted chat's name from the local chat store, never fetched. A DM reads as its + /// partner, as the chat list does. `nil` when the chat is not stored. + private var boostChatName: String? { + guard let boost = activity.boostedReaction else { return nil } + let controller = sessionContainer.conversationController + return controller.conversation(withID: boost.conversationID).map(controller.displayName(for:)) + } + var body: some View { Background(color: .backgroundMain) { ScrollView(.vertical, showsIndicators: false) { @@ -198,6 +207,12 @@ struct TransactionDetailsScreen: View { ReceiptRow(label: "Currency", value: details.currency.rawValue.uppercased()) ReceiptRow(label: "Exchange Rate", value: Self.rateFormatter.string(for: details.exchangeRate) ?? "") ReceiptRow(label: "Date", value: details.date.formatted(Self.dateFormat)) + if let emoji = details.boostEmoji { + ReceiptRow(label: "Reaction", value: emoji) + } + if let chatName = details.boostChatName { + ReceiptRow(label: "Chat", value: chatName) + } ReceiptRow(label: "Tokens", value: details.tokenAmount.formattedQuantity()) if let fee = details.fee { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift index 8747e06ef..c33495d9f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -136,6 +136,13 @@ public struct TransactionDetails: Sendable, Equatable, Hashable { /// when the payment had no counterparty to open. public let chatAction: ChatAction? + /// The boosted emoji, for the receipt's Reaction row. `nil` for a payment that boosted nothing. + public let boostEmoji: String? + + /// The boosted chat's display name, for the receipt's Chat row. `nil` when the payment boosted + /// nothing or the chat is not in the local store, which leaves the row out. + public let boostChatName: String? + public enum ChatAction: Sendable, Equatable, Hashable { /// Opens the message a boost was for. case viewMessage(Activity.BoostedReaction) @@ -173,14 +180,33 @@ extension TransactionDetails { counterpartyName: String? = nil, fromTokenName: String? = nil, toTokenName: String? = nil, + boostChatName: String? = nil, ) { let kind = TransactionKind(activity: activity) let swap = activity.swapMetadata self.id = activity.id.base58 self.kind = kind - self.heading = counterpartyName?.trimmingCharacters(in: .whitespaces).nilIfEmpty - self.subtitle = Self.subtitle(kind: kind, fromTokenName: fromTokenName, toTokenName: toTokenName) + let name = counterpartyName?.trimmingCharacters(in: .whitespaces).nilIfEmpty + let chatName = boostChatName?.trimmingCharacters(in: .whitespaces).nilIfEmpty + + if let boost = activity.boostedReaction { + // A boost wins over the tip heading. The name stays the large heading and the boost + // line goes under it; with no name to lead, the boost line is the heading. + let boostLine = activity.kind == .received ? Self.boostReceivedHeading : Self.boostedHeading + // A split boost has no counterparty, so the chat names it; a counterparty that has not + // resolved yet must not borrow the chat's name. + let lead = name ?? (activity.counterparty == nil ? chatName : nil) + self.heading = lead ?? boostLine + self.subtitle = lead == nil ? nil : boostLine + self.boostEmoji = boost.emoji + self.boostChatName = chatName + } else { + self.heading = name + self.subtitle = Self.subtitle(kind: kind, fromTokenName: fromTokenName, toTokenName: toTokenName) + self.boostEmoji = nil + self.boostChatName = nil + } self.signPrefix = activity.kind.signPrefix self.amount = activity.exchangedFiat self.date = activity.date @@ -202,6 +228,9 @@ extension TransactionDetails { } } + static let boostedHeading = "You boosted" + static let boostReceivedHeading = "Boost received" + /// The other side of the movement, for the kinds whose heading doesn't /// already carry it. `nil` wherever the header already says everything, and /// wherever the feed can't answer it: a buy or a sell records only the mint diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift index 68ff5ec61..ddd131782 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift @@ -179,6 +179,102 @@ struct TransactionDetailsTests { #expect(alone.chatAction == nil) } + // MARK: - Boost header and card rows + + private func boostDetails( + kind: Activity.Kind = .gave, + title: String = "Sent", + counterparty: Activity.Counterparty? = nil, + boosted: Bool = true, + name: String? = nil, + chat: String? = nil + ) -> TransactionDetails { + TransactionDetails( + activity: Activity( + id: .jeffy, state: .completed, kind: kind, title: title, + exchangedFiat: Self.usd(1), date: .now, metadata: nil, + counterparty: counterparty, boostedReaction: boosted ? boost() : nil + ), + counterpartyName: name, + boostChatName: chat + ) + } + + @Test("H1: a sent boost keeps the name as the heading, with the boost line under it") + func sentBoostHeader() { + let details = boostDetails(counterparty: counterparty, name: "Sally") + #expect(details.title == "Sally") + #expect(details.subtitle == "You boosted") + } + + @Test("H2: a received boost reads Boost received under the name") + func receivedBoostHeader() { + let details = boostDetails(kind: .received, title: "Received", counterparty: counterparty, name: "Sally") + #expect(details.title == "Sally") + #expect(details.subtitle == "Boost received") + } + + @Test("H3: a boost wins over the tip heading") + func boostBeatsTip() { + let unresolved = boostDetails(title: "Tipped Sally", counterparty: counterparty) + #expect(unresolved.title == "You boosted") + #expect(unresolved.subtitle == nil) + + let resolved = boostDetails(title: "Tipped Sally", counterparty: counterparty, name: "Sally") + #expect(resolved.title == "Sally") + #expect(resolved.subtitle == "You boosted") + } + + @Test("H4: a split boost is headed by the stored chat's name") + func splitBoostWithChat() { + let details = boostDetails(chat: "Design Team") + #expect(details.title == "Design Team") + #expect(details.subtitle == "You boosted") + } + + @Test("H5: a split boost with no stored chat is headed by the boost line alone") + func splitBoostWithoutChat() { + let details = boostDetails() + #expect(details.title == "You boosted") + #expect(details.subtitle == nil) + } + + @Test("H6: without a boost the header is unchanged") + func noBoostHeader() { + let details = boostDetails(title: "Tipped Sally", counterparty: counterparty, boosted: false, name: "Sally", chat: "Design Team") + #expect(details.title == "Sally") + #expect(details.subtitle == nil) + #expect(details.boostEmoji == nil) + } + + @Test("C1: a boost in a stored chat shows both rows") + func cardRowsWithChat() { + let details = boostDetails(chat: "Design Team") + #expect(details.boostEmoji == "🔥") + #expect(details.boostChatName == "Design Team") + } + + @Test("C2: a boost in an unstored chat shows the reaction only") + func cardRowsWithoutChat() { + let details = boostDetails(chat: nil) + #expect(details.boostEmoji == "🔥") + #expect(details.boostChatName == nil) + #expect(boostDetails(chat: " ").boostChatName == nil) + } + + @Test("C3: a DM boost carries the partner's name as the chat") + func cardRowsDM() { + let details = boostDetails(counterparty: counterparty, name: "Sally", chat: "Sally") + #expect(details.boostChatName == "Sally") + } + + @Test("C4: without a boost neither row is shown") + func cardRowsNoBoost() { + let details = boostDetails(boosted: false, chat: "Design Team") + #expect(details.boostEmoji == nil) + #expect(details.boostChatName == nil) + } + // MARK: - Actions @Test("Only an open cash link can be cancelled") From 772c3a04db990ee0c02a608b6c9a083f24df645a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 26 Oct 2026 13:09:17 -0400 Subject: [PATCH 3/3] feat(history): move the boost emoji and chat into the detail subtitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line under the heading now reads 'You boosted 🔥 in {chat}' or 'Boost 🔥 received in {chat}' for a stored group, and drops 'in {chat}' for a DM or a chat not in the local store. A split boost keeps the chat as its heading with no 'in'. The Reaction and Chat receipt rows are removed. --- .../Main/Home/TransactionDetailsScreen.swift | 21 ++-- .../Models/TransactionDetails.swift | 31 +++--- .../TransactionDetailsTests.swift | 100 +++++++++--------- 3 files changed, 76 insertions(+), 76 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index 2ae6cca69..4d00fd2b6 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -54,16 +54,19 @@ struct TransactionDetailsScreen: View { counterpartyName: resolution.counterpartyName, fromTokenName: fromTokenName, toTokenName: toTokenName, - boostChatName: boostChatName, + boostChatName: boostChat?.name, + boostChatIsGroup: boostChat?.isGroup ?? false, ) } - /// The boosted chat's name from the local chat store, never fetched. A DM reads as its - /// partner, as the chat list does. `nil` when the chat is not stored. - private var boostChatName: String? { + /// The boosted chat's name and kind from the local chat store, never fetched. A DM reads as + /// its partner, as the chat list does. `nil` when the chat is not stored. + private var boostChat: (name: String, isGroup: Bool)? { guard let boost = activity.boostedReaction else { return nil } let controller = sessionContainer.conversationController - return controller.conversation(withID: boost.conversationID).map(controller.displayName(for:)) + return controller.conversation(withID: boost.conversationID).map { + (controller.displayName(for: $0), $0.type == .group) + } } var body: some View { @@ -152,6 +155,8 @@ struct TransactionDetailsScreen: View { .font(.appTextSmall) .foregroundStyle(Color.textSecondary) .multilineTextAlignment(.center) + .lineLimit(2) + .truncationMode(.tail) } } @@ -207,12 +212,6 @@ struct TransactionDetailsScreen: View { ReceiptRow(label: "Currency", value: details.currency.rawValue.uppercased()) ReceiptRow(label: "Exchange Rate", value: Self.rateFormatter.string(for: details.exchangeRate) ?? "") ReceiptRow(label: "Date", value: details.date.formatted(Self.dateFormat)) - if let emoji = details.boostEmoji { - ReceiptRow(label: "Reaction", value: emoji) - } - if let chatName = details.boostChatName { - ReceiptRow(label: "Chat", value: chatName) - } ReceiptRow(label: "Tokens", value: details.tokenAmount.formattedQuantity()) if let fee = details.fee { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift index c33495d9f..8f06c4df3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -136,13 +136,6 @@ public struct TransactionDetails: Sendable, Equatable, Hashable { /// when the payment had no counterparty to open. public let chatAction: ChatAction? - /// The boosted emoji, for the receipt's Reaction row. `nil` for a payment that boosted nothing. - public let boostEmoji: String? - - /// The boosted chat's display name, for the receipt's Chat row. `nil` when the payment boosted - /// nothing or the chat is not in the local store, which leaves the row out. - public let boostChatName: String? - public enum ChatAction: Sendable, Equatable, Hashable { /// Opens the message a boost was for. case viewMessage(Activity.BoostedReaction) @@ -181,6 +174,7 @@ extension TransactionDetails { fromTokenName: String? = nil, toTokenName: String? = nil, boostChatName: String? = nil, + boostChatIsGroup: Bool = false, ) { let kind = TransactionKind(activity: activity) let swap = activity.swapMetadata @@ -193,19 +187,19 @@ extension TransactionDetails { if let boost = activity.boostedReaction { // A boost wins over the tip heading. The name stays the large heading and the boost // line goes under it; with no name to lead, the boost line is the heading. - let boostLine = activity.kind == .received ? Self.boostReceivedHeading : Self.boostedHeading + let received = activity.kind == .received // A split boost has no counterparty, so the chat names it; a counterparty that has not // resolved yet must not borrow the chat's name. - let lead = name ?? (activity.counterparty == nil ? chatName : nil) - self.heading = lead ?? boostLine - self.subtitle = lead == nil ? nil : boostLine - self.boostEmoji = boost.emoji - self.boostChatName = chatName + let chatLeads = name == nil && activity.counterparty == nil && chatName != nil + let lead = name ?? (chatLeads ? chatName : nil) + // "in {chat}" only for a stored group, and never when the chat already is the heading. + let place = chatLeads || !boostChatIsGroup ? nil : chatName + let line = Self.boostLine(emoji: boost.emoji, received: received, chat: place) + self.heading = lead ?? line + self.subtitle = lead == nil ? nil : line } else { self.heading = name self.subtitle = Self.subtitle(kind: kind, fromTokenName: fromTokenName, toTokenName: toTokenName) - self.boostEmoji = nil - self.boostChatName = nil } self.signPrefix = activity.kind.signPrefix self.amount = activity.exchangedFiat @@ -228,8 +222,11 @@ extension TransactionDetails { } } - static let boostedHeading = "You boosted" - static let boostReceivedHeading = "Boost received" + /// "You boosted 🔥", or "Boost 🔥 received", with " in {chat}" when `chat` is given. + static func boostLine(emoji: String, received: Bool, chat: String?) -> String { + let base = received ? "Boost \(emoji) received" : "You boosted \(emoji)" + return chat.map { "\(base) in \($0)" } ?? base + } /// The other side of the movement, for the kinds whose heading doesn't /// already carry it. `nil` wherever the header already says everything, and diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift index ddd131782..94584c273 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift @@ -179,7 +179,7 @@ struct TransactionDetailsTests { #expect(alone.chatAction == nil) } - // MARK: - Boost header and card rows + // MARK: - Boost header private func boostDetails( kind: Activity.Kind = .gave, @@ -187,7 +187,8 @@ struct TransactionDetailsTests { counterparty: Activity.Counterparty? = nil, boosted: Bool = true, name: String? = nil, - chat: String? = nil + chat: String? = nil, + isGroup: Bool = false ) -> TransactionDetails { TransactionDetails( activity: Activity( @@ -196,85 +197,88 @@ struct TransactionDetailsTests { counterparty: counterparty, boostedReaction: boosted ? boost() : nil ), counterpartyName: name, - boostChatName: chat + boostChatName: chat, + boostChatIsGroup: isGroup ) } - @Test("H1: a sent boost keeps the name as the heading, with the boost line under it") - func sentBoostHeader() { - let details = boostDetails(counterparty: counterparty, name: "Sally") - #expect(details.title == "Sally") - #expect(details.subtitle == "You boosted") + @Test("H1: a sent boost in a stored group names the chat under the name") + func sentBoostInGroup() { + let details = boostDetails(counterparty: counterparty, name: "Grampa", chat: "Grampa's Photo Community", isGroup: true) + #expect(details.title == "Grampa") + #expect(details.subtitle == "You boosted 🔥 in Grampa's Photo Community") } - @Test("H2: a received boost reads Boost received under the name") - func receivedBoostHeader() { - let details = boostDetails(kind: .received, title: "Received", counterparty: counterparty, name: "Sally") + @Test("H2: a sent boost in a DM drops the chat") + func sentBoostInDM() { + let details = boostDetails(counterparty: counterparty, name: "Sally", chat: "Sally") #expect(details.title == "Sally") - #expect(details.subtitle == "Boost received") + #expect(details.subtitle == "You boosted 🔥") } @Test("H3: a boost wins over the tip heading") func boostBeatsTip() { - let unresolved = boostDetails(title: "Tipped Sally", counterparty: counterparty) - #expect(unresolved.title == "You boosted") - #expect(unresolved.subtitle == nil) - - let resolved = boostDetails(title: "Tipped Sally", counterparty: counterparty, name: "Sally") - #expect(resolved.title == "Sally") - #expect(resolved.subtitle == "You boosted") + let details = boostDetails(title: "Tipped Sally", counterparty: counterparty, name: "Sally", chat: "Sally") + #expect(details.title == "Sally") + #expect(details.subtitle == "You boosted 🔥") } - @Test("H4: a split boost is headed by the stored chat's name") + @Test("H4: a split boost is headed by the stored chat, with no \"in\"") func splitBoostWithChat() { - let details = boostDetails(chat: "Design Team") + let details = boostDetails(chat: "Design Team", isGroup: true) #expect(details.title == "Design Team") - #expect(details.subtitle == "You boosted") + #expect(details.subtitle == "You boosted 🔥") } @Test("H5: a split boost with no stored chat is headed by the boost line alone") func splitBoostWithoutChat() { let details = boostDetails() - #expect(details.title == "You boosted") + #expect(details.title == "You boosted 🔥") #expect(details.subtitle == nil) } - @Test("H6: without a boost the header is unchanged") - func noBoostHeader() { - let details = boostDetails(title: "Tipped Sally", counterparty: counterparty, boosted: false, name: "Sally", chat: "Design Team") + @Test("H6: a received boost in a stored group names the chat") + func receivedBoostInGroup() { + let details = boostDetails(kind: .received, title: "Received", counterparty: counterparty, name: "Sally", chat: "Design Team", isGroup: true) #expect(details.title == "Sally") - #expect(details.subtitle == nil) - #expect(details.boostEmoji == nil) + #expect(details.subtitle == "Boost 🔥 received in Design Team") } - @Test("C1: a boost in a stored chat shows both rows") - func cardRowsWithChat() { - let details = boostDetails(chat: "Design Team") - #expect(details.boostEmoji == "🔥") - #expect(details.boostChatName == "Design Team") + @Test("H7: a received boost in a DM drops the chat") + func receivedBoostInDM() { + let details = boostDetails(kind: .received, title: "Received", counterparty: counterparty, name: "Sally", chat: "Sally") + #expect(details.title == "Sally") + #expect(details.subtitle == "Boost 🔥 received") } - @Test("C2: a boost in an unstored chat shows the reaction only") - func cardRowsWithoutChat() { - let details = boostDetails(chat: nil) - #expect(details.boostEmoji == "🔥") - #expect(details.boostChatName == nil) - #expect(boostDetails(chat: " ").boostChatName == nil) + @Test("H8: a boost in a chat that is not stored drops the chat") + func boostChatNotStored() { + let details = boostDetails(counterparty: counterparty, name: "Grampa") + #expect(details.title == "Grampa") + #expect(details.subtitle == "You boosted 🔥") } - @Test("C3: a DM boost carries the partner's name as the chat") - func cardRowsDM() { - let details = boostDetails(counterparty: counterparty, name: "Sally", chat: "Sally") - #expect(details.boostChatName == "Sally") + @Test("H9: an unresolved counterparty never borrows the chat name as the heading") + func unresolvedCounterparty() { + let group = boostDetails(counterparty: counterparty, chat: "Design Team", isGroup: true) + #expect(group.title == "You boosted 🔥 in Design Team") + #expect(group.subtitle == nil) + + let dm = boostDetails(counterparty: counterparty, chat: "Sally") + #expect(dm.title == "You boosted 🔥") + #expect(dm.subtitle == nil) } - @Test("C4: without a boost neither row is shown") - func cardRowsNoBoost() { - let details = boostDetails(boosted: false, chat: "Design Team") - #expect(details.boostEmoji == nil) - #expect(details.boostChatName == nil) + @Test("H10: without a boost the header is unchanged") + func noBoostHeader() { + let details = boostDetails(title: "Tipped Sally", counterparty: counterparty, boosted: false, name: "Sally", chat: "Design Team", isGroup: true) + #expect(details.title == "Sally") + #expect(details.subtitle == nil) } + // H11 (no Reaction or Chat receipt row) holds by construction: TransactionDetails carries + // neither value and the receipt card has no row for them. + // MARK: - Actions @Test("Only an open cash link can be cancelled")