diff --git a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift index 2a0d483d3..3435b96cf 100644 --- a/Flipcash/Core/Screens/Main/Home/ActivityRow.swift +++ b/Flipcash/Core/Screens/Main/Home/ActivityRow.swift @@ -12,9 +12,9 @@ import FlipcashCore /// Transaction history (Figma 8966:1910, ported from Android's `ActivityFeedRow`): /// a 40pt avatar, the title + relative time, and a signed amount. The avatar is /// the counterparty's profile photo for peer activity (tips/sends), the token -/// image for token activity (deposits, buys), or a monogram fallback. A sent tip -/// reads "Tipped " once the counterparty resolves, and a swap reads -/// "" once both token names resolve. +/// image for token activity (deposits, buys), or a monogram fallback. A peer +/// payment reads "Tipped " or "Sent to " once the counterparty +/// resolves, and a swap reads "" once both token names resolve. struct ActivityRow: View { let activity: Activity @@ -87,9 +87,8 @@ struct ActivityRow: View { // MARK: - Title - /// Peer tips render with the resolved counterparty name — "Tipped " - /// for a sent tip, "Tip from " for a received one — and a swap renders - /// its two token names, once they resolve; every other row uses the + /// A peer payment renders with the resolved counterparty name, and a swap + /// renders its two token names, once they resolve; every other row uses the /// server-rendered title. private var displayTitle: String { if let swap = activity.swapMetadata { @@ -99,14 +98,38 @@ struct ActivityRow: View { ) ?? activity.title } - if let name = counterpartyName, !name.isEmpty { - switch activity.kind { - case .gave: return "Tipped \(name)" - case .received: return "Tip from \(name)" - default: break - } + return Self.peerTitle( + kind: activity.kind, + name: counterpartyName, + serverTitle: activity.title + ) ?? activity.title + } + + /// 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 + /// payment, or whose counterparty has not resolved yet, so the caller falls + /// back to the server-rendered title. + /// + /// The tip/send split rides on the server's verb alone: the backend picks it + /// from the payment's `ChatMetadata.TipDmPayment.Location`, so a tip-card tip + /// arrives titled "Tipped" and an in-chat send "Sent". `activity/v1` models + /// both as plain sent/received crypto with no structured tip flag, so there + /// is no other signal to read. Matching the verb is safe while `serverTitle` + /// is English-only; a localized feed would need the distinction promoted into + /// the notification metadata. + static func peerTitle(kind: Activity.Kind, name: String?, serverTitle: String) -> String? { + guard let name, !name.isEmpty else { return nil } + let isTip = serverTitle + .trimmingCharacters(in: .whitespaces) + .lowercased() + .hasPrefix("tip") + + switch kind { + case .gave: return isTip ? "Tipped \(name)" : "Sent to \(name)" + case .received: return isTip ? "Tip from \(name)" : "Received from \(name)" + default: return nil } - return activity.title } /// The conversion pair a swap row is titled with, or `nil` when either leg's @@ -247,8 +270,8 @@ struct ActivityRow: View { /// A cached full profile (someone you've viewed or tipped) is authoritative; /// otherwise fall back to the tip conversation's member, which carries the /// name + picture for counterparties you've only *received* tips from (those - /// are never written to the profile cache). Without this, received tips show - /// the server title and a monogram instead of "Tip from ". + /// are never written to the profile cache). Without this, received payments + /// show the server title and a monogram instead of a named row. private func resolveUser(_ userID: UserID) async { let picture: ProfilePicture? diff --git a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift index 56d323e2e..6687b738b 100644 --- a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift +++ b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift @@ -195,12 +195,12 @@ final class SendAmountViewModel { return .failed } - // A payment into a tip DM reports as a tip; a contact DM is a plain - // cash send. This covers both the scanned-tipcard flow and the - // Send Cash action inside a tip thread, since both submit here — - // `Origin` is what tells the two apart. - let tipOrigin: TipOrigin? = if case .tip(let recipient) = target { recipient.origin } else { nil } - let transferEvent: Analytics.TransferEvent = tipOrigin == nil ? .sentCash : .sentTip + // Only a tip card payment is a tip — the same line the activity feed + // draws, from `ChatMetadata.TipDmPayment.Location`. Both the scanned + // tipcard flow and the Send Cash action inside a tip thread submit + // here, and the latter reports as a plain cash send. + let isTip = if case .tip(let recipient) = target { recipient.origin == .tipcard } else { false } + let transferEvent: Analytics.TransferEvent = isTip ? .sentTip : .sentCash do { try await sender.send( @@ -209,10 +209,10 @@ final class SendAmountViewModel { to: recipient, chat: chatPaymentMetadata() ) - Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: true, error: nil, origin: tipOrigin) + Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: true, error: nil) return .success } catch { - Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: false, error: error, origin: tipOrigin) + Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: false, error: error) showSendError() return .failed } diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index c62a3e8e2..4709170ff 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -204,9 +204,7 @@ extension Analytics { ) } - /// `origin` applies to `Sent Tip` only: which surface the tip came from — - /// a scanned/opened tip card, or the money button inside an existing tip chat. - static func transfer(event: TransferEvent, exchangedFiat: ExchangedFiat?, grabTime: Double?, successful: Bool, error: Error?, origin: TipOrigin? = nil) { + static func transfer(event: TransferEvent, exchangedFiat: ExchangedFiat?, grabTime: Double?, successful: Bool, error: Error?) { var properties: [Property: AnalyticsValue] = exchangedFiat.map(amountProperties) ?? [:] properties[.state] = successful ? String.success : String.failure @@ -214,10 +212,6 @@ extension Analytics { properties[.grabTime] = grabTime } - if let origin { - properties[.origin] = origin.analyticsValue - } - track( event: event, properties: properties, @@ -358,16 +352,6 @@ extension DepositMethod { } } -extension TipOrigin { - /// The `Origin` property value, shared verbatim with Android. - var analyticsValue: String { - switch self { - case .tipcard: "Tipcard" - case .chat: "Chat" - } - } -} - // MARK: - Wallet - extension Analytics { @@ -478,7 +462,6 @@ extension Analytics { case state = "State" case source = "Source" - case origin = "Origin" case method = "Method" case quarks = "Quarks" case mint = "Mint" diff --git a/FlipcashTests/ActivityRowPeerTitleTests.swift b/FlipcashTests/ActivityRowPeerTitleTests.swift new file mode 100644 index 000000000..17df754b1 --- /dev/null +++ b/FlipcashTests/ActivityRowPeerTitleTests.swift @@ -0,0 +1,43 @@ +// +// ActivityRowPeerTitleTests.swift +// FlipcashTests +// + +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("Peer payment row title") +struct ActivityRowPeerTitleTests { + + @Test("A tip keeps the tip phrasing on both sides") + func tipPhrasing() { + #expect(ActivityRow.peerTitle(kind: .gave, name: "Sally", serverTitle: "Tipped") == "Tipped Sally") + #expect(ActivityRow.peerTitle(kind: .received, name: "Sally", serverTitle: "Tipped") == "Tip from Sally") + } + + @Test("An in-chat send reads as a send, not a tip") + func sendPhrasing() { + #expect(ActivityRow.peerTitle(kind: .gave, name: "Sally", serverTitle: "Sent") == "Sent to Sally") + #expect(ActivityRow.peerTitle(kind: .received, name: "Sally", serverTitle: "Sent") == "Received from Sally") + #expect(ActivityRow.peerTitle(kind: .received, name: "Sally", serverTitle: "Received") == "Received from Sally") + } + + @Test("The verb is read from an already-substituted title") + func substitutedTitle() { + #expect(ActivityRow.peerTitle(kind: .received, name: "Sally", serverTitle: "Tipped Bob") == "Tip from Sally") + #expect(ActivityRow.peerTitle(kind: .received, name: "Sally", serverTitle: "Sent to Bob") == "Received from Sally") + } + + @Test("An unresolved counterparty falls back to the server-rendered title") + func unresolvedCounterpartyFallsBack() { + #expect(ActivityRow.peerTitle(kind: .received, name: nil, serverTitle: "Sent") == nil) + #expect(ActivityRow.peerTitle(kind: .received, name: "", serverTitle: "Sent") == nil) + } + + @Test("Non-peer activity falls back to the server-rendered title") + func nonPeerActivityFallsBack() { + #expect(ActivityRow.peerTitle(kind: .bought, name: "Sally", serverTitle: "Purchased") == nil) + #expect(ActivityRow.peerTitle(kind: .withdrew, name: "Sally", serverTitle: "Withdrew") == nil) + } +} diff --git a/FlipcashTests/Analytics/ReceivedEventsTests.swift b/FlipcashTests/Analytics/ReceivedEventsTests.swift index f41ddc01e..599e22b7f 100644 --- a/FlipcashTests/Analytics/ReceivedEventsTests.swift +++ b/FlipcashTests/Analytics/ReceivedEventsTests.swift @@ -4,19 +4,12 @@ // import Testing -import FlipcashCore @testable import Flipcash @MainActor -@Suite("Received & origin event contract") +@Suite("Received event contract") struct ReceivedEventsTests { - @Test("Tip origin property values are shared verbatim with Android") - func tipOriginValues() { - #expect(TipOrigin.tipcard.analyticsValue == "Tipcard") - #expect(TipOrigin.chat.analyticsValue == "Chat") - } - @Test("Display name event names are the shared contract") func displayNameEventNames() { #expect(Analytics.DisplayNameEvent.set.eventName == "Display Name Set")