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..4d00fd2b6 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -54,9 +54,21 @@ struct TransactionDetailsScreen: View { counterpartyName: resolution.counterpartyName, fromTokenName: fromTokenName, toTokenName: toTokenName, + boostChatName: boostChat?.name, + boostChatIsGroup: boostChat?.isGroup ?? false, ) } + /// 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: $0), $0.type == .group) + } + } + var body: some View { Background(color: .backgroundMain) { ScrollView(.vertical, showsIndicators: false) { @@ -70,7 +82,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 +101,8 @@ struct TransactionDetailsScreen: View { router.push(DMRoute.destination(for: userID, dmID: dmID, origin: .transaction)) } .buttonStyle(.filled05) + case nil: + EmptyView() } } } @@ -135,6 +155,8 @@ struct TransactionDetailsScreen: View { .font(.appTextSmall) .foregroundStyle(Color.textSecondary) .multilineTextAlignment(.center) + .lineLimit(2) + .truncationMode(.tail) } } @@ -297,6 +319,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..8f06c4df3 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 } @@ -162,14 +173,34 @@ extension TransactionDetails { counterpartyName: String? = nil, fromTokenName: String? = nil, toTokenName: String? = nil, + boostChatName: String? = nil, + boostChatIsGroup: Bool = false, ) { 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 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 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.signPrefix = activity.kind.signPrefix self.amount = activity.exchangedFiat self.date = activity.date @@ -181,6 +212,20 @@ 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 + } + } + + /// "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 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..94584c273 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransactionDetailsTests.swift @@ -130,6 +130,155 @@ 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: - Boost header + + private func boostDetails( + kind: Activity.Kind = .gave, + title: String = "Sent", + counterparty: Activity.Counterparty? = nil, + boosted: Bool = true, + name: String? = nil, + chat: String? = nil, + isGroup: Bool = false + ) -> 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, + boostChatIsGroup: isGroup + ) + } + + @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 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 == "You boosted 🔥") + } + + @Test("H3: a boost wins over the tip heading") + func boostBeatsTip() { + 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, with no \"in\"") + func splitBoostWithChat() { + let details = boostDetails(chat: "Design Team", isGroup: true) + #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: 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 == "Boost 🔥 received in 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("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("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("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") 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) + } +}