Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 38 additions & 15 deletions Flipcash/Core/Screens/Main/Home/ActivityRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>" once the counterparty resolves, and a swap reads
/// "<From> → <To>" once both token names resolve.
/// image for token activity (deposits, buys), or a monogram fallback. A peer
/// payment reads "Tipped <name>" or "Sent to <name>" once the counterparty
/// resolves, and a swap reads "<From> → <To>" once both token names resolve.
struct ActivityRow: View {

let activity: Activity
Expand Down Expand Up @@ -87,9 +87,8 @@ struct ActivityRow: View {

// MARK: - Title

/// Peer tips render with the resolved counterparty name — "Tipped <name>"
/// for a sent tip, "Tip from <name>" 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 {
Expand All @@ -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
/// <name>" / "Tip from <name>" for a tip, "Sent to <name>" / "Received from
/// <name>" 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
Expand Down Expand Up @@ -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 <name>".
/// 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?

Expand Down
16 changes: 8 additions & 8 deletions Flipcash/Core/Screens/Send/SendAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
}
Expand Down
19 changes: 1 addition & 18 deletions Flipcash/Utilities/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,20 +204,14 @@ 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

if let grabTime {
properties[.grabTime] = grabTime
}

if let origin {
properties[.origin] = origin.analyticsValue
}

track(
event: event,
properties: properties,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -478,7 +462,6 @@ extension Analytics {

case state = "State"
case source = "Source"
case origin = "Origin"
case method = "Method"
case quarks = "Quarks"
case mint = "Mint"
Expand Down
43 changes: 43 additions & 0 deletions FlipcashTests/ActivityRowPeerTitleTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 1 addition & 8 deletions FlipcashTests/Analytics/ReceivedEventsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down