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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 54 additions & 1 deletion Flipcash/Core/Screens/Main/Home/ActivityRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <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
Expand Down
38 changes: 37 additions & 1 deletion Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -83,6 +101,8 @@ struct TransactionDetailsScreen: View {
router.push(DMRoute.destination(for: userID, dmID: dmID, origin: .transaction))
}
.buttonStyle(.filled05)
case nil:
EmptyView()
}
}
}
Expand Down Expand Up @@ -135,6 +155,8 @@ struct TransactionDetailsScreen: View {
.font(.appTextSmall)
.foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.truncationMode(.tail)
}
}

Expand Down Expand Up @@ -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 }

Expand Down
3 changes: 1 addition & 2 deletions FlipcashCore/Sources/FlipcashCore/Models/Activity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
49 changes: 47 additions & 2 deletions FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
53 changes: 50 additions & 3 deletions FlipcashCore/Sources/FlipcashStore/Database+Activities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?

Expand Down Expand Up @@ -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 ?;
Expand Down Expand Up @@ -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<Data?>("boostConversationID")],
let messageID = row[Expression<UInt64?>("boostMessageID")],
let emoji = row[Expression<String?>("boostEmoji")]
else {
return nil
}

return Activity.BoostedReaction(
conversationID: ConversationID(data: conversationID),
messageID: MessageID(value: messageID),
emoji: emoji
)
}

Expand Down Expand Up @@ -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())
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion FlipcashCore/Sources/FlipcashStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
28 changes: 28 additions & 0 deletions FlipcashCore/Sources/FlipcashStore/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ nonisolated public struct SwapMetadataTable: Sendable {
public let state = Expression <Int> ("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 <PublicKey> ("id")
public let conversationID = Expression <Data> ("conversationID")
public let messageID = Expression <UInt64> ("messageID")
public let emoji = Expression <String> ("emoji")
}

nonisolated public struct LimitsTable: Sendable {
public static let name = "limits"

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading