Skip to content
Merged
1,650 changes: 1,650 additions & 0 deletions .claude/plans/2026-08-21-analytics-received-events-ios.md

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ final class ConversationController {
/// out-of-order event may close it) before spending a `GetDelta`.
@ObservationIgnored private var gapCatchUpTasks: [ConversationID: Task<Void, Never>] = [:]
@ObservationIgnored private let receiptSettle = ReceiptSettleGate()
/// The receive-side analytics concern (cumulative counters + received events),
/// owned by its own unit. Exposed so `SessionContainer` can wire its rate lookup.
@ObservationIgnored let receipts: ConversationReceiptReporter

/// The typing-indicator concern, both directions (outgoing driver + incoming typist
/// tracking), owned by its own unit. Reads chain through `@Observable` tracking.
Expand All @@ -146,7 +149,8 @@ final class ConversationController {
selfUserID: UserID,
typingHeartbeatInterval: Duration = .seconds(3),
typingTimeout: Duration = .seconds(5),
incomingTypingExpiry: Duration = .seconds(10)
incomingTypingExpiry: Duration = .seconds(10),
receipts: ConversationReceiptReporter? = nil
) {
self.fetching = fetching
self.messaging = messaging
Expand All @@ -155,6 +159,7 @@ final class ConversationController {
self.database = database
self.owner = owner
self.selfUserID = selfUserID
self.receipts = receipts ?? ConversationReceiptReporter(selfUserID: selfUserID)
self.typing = ConversationTyping(
messaging: messaging,
owner: owner,
Expand Down Expand Up @@ -278,6 +283,9 @@ final class ConversationController {
defer { catchUpInFlight.remove(conversationID) }

let after = store.appliedCursor(for: conversationID)
// One baseline for the whole run. A per-batch watermark would let the second
// batch of a cold backfill count everything the first batch just seeded.
let countedThrough = (try? database.newestMessageID(conversationID: conversationID)) ?? nil
do {
var anyBatchFailed = false
let head = try await messaging.getDelta(owner: owner, conversationID: conversationID, afterSequence: after) { [weak self] messages, checkpoint in
Expand All @@ -291,6 +299,7 @@ final class ConversationController {
}
if ok {
self.commitReconciled(pairs, in: conversationID)
self.receipts.countReceived(reconciled, countedThrough: countedThrough, delivery: .catchUp)
if let checkpoint { self.store.setAppliedCursor(checkpoint, for: conversationID) }
} else {
anyBatchFailed = true
Expand Down Expand Up @@ -497,17 +506,22 @@ final class ConversationController {
private func persist(event: ConversationStreamEvent) {
switch event {
case .newMessages(let conversationID, let messages):
// Read before the write: the newest stored id is the analytics watermark,
// and after the upsert it would already include this batch.
let countedThrough = (try? database.newestMessageID(conversationID: conversationID)) ?? nil
let (reconciled, pairs) = reconciledForPersist(messages, in: conversationID)
let ok = persist(operation: "upsert-messages") { try database.upsertConversationMessages(reconciled, conversationID: conversationID) }
if ok {
commitReconciled(pairs, in: conversationID)
receipts.countReceived(reconciled, countedThrough: countedThrough, delivery: .live)
} else {
// The delivered batch is in neither the DB nor the store — refetch it from the event log.
scheduleGapCatchUp(conversationID)
}
refreshFeedPreview(for: conversationID)
persistConversation(conversationID)
case .chatEvents(let conversationID, let events):
let countedThrough = (try? database.newestMessageID(conversationID: conversationID)) ?? nil
let (reconciled, pairs) = reconciledForPersist(events.flatMap { $0.mutations.map(\.message) }, in: conversationID)
// Messages + the advanced cursor persist atomically. `store.apply` already advanced the
// in-memory cursor optimistically, so if this write rolls back, re-seat the cursor to the
Expand All @@ -518,6 +532,7 @@ final class ConversationController {
}
if ok {
commitReconciled(pairs, in: conversationID)
receipts.countReceived(reconciled, countedThrough: countedThrough, delivery: .live)
} else {
store.reseatCursor((try? database.catchupCursor(conversationID: conversationID)) ?? 0, for: conversationID)
scheduleGapCatchUp(conversationID)
Expand Down Expand Up @@ -933,12 +948,17 @@ final class ConversationController {
guard let latestID = (try? database.newestMessageID(conversationID: conversationID)).flatMap({ $0 }) else { return }
// Skip the round-trip when the server-known READ watermark already covers
// the latest message. We advance the watermark locally after each success.
if let read = store.selfReadPointer(for: conversationID, selfUserID: selfUserID), latestID <= read {
let previousRead = store.selfReadPointer(for: conversationID, selfUserID: selfUserID)
if let previousRead, latestID <= previousRead {
return
}
do {
try await messaging.markRead(owner: owner, conversationID: conversationID, messageID: latestID)
store.advanceSelfReadPointer(to: latestID, in: conversationID, selfUserID: selfUserID)
// The read pointer only moves forward, so the window it just crossed is
// exactly the set of messages the user is seeing for the first time.
let crossed = (try? database.messages(conversationID: conversationID, after: previousRead, through: latestID)) ?? []
receipts.reportRead(crossed, chatType: conversation(withID: conversationID)?.type)
persistConversation(conversationID)
} catch {
logger.error("Failed to mark conversation read", metadata: [
Expand Down
117 changes: 117 additions & 0 deletions Flipcash/Core/Controllers/ConversationReceiptReporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//
// ConversationReceiptReporter.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import FlipcashCore

/// The receive-side analytics concern, split out of `ConversationController`: which
/// delivered messages count toward the cumulative received counters, what a
/// read-pointer advance reports, and how a foreign-currency tip becomes a USD number.
///
/// Every dependency is an injectable closure, defaulting to the real `Analytics`
/// entry points, so the rules can be exercised without the Mixpanel transport (which
/// is inert in tests).
@MainActor
final class ConversationReceiptReporter {

/// How a batch of messages reached the client. The distinction only matters when
/// the client holds nothing for the conversation yet: a **live** delivery into an
/// empty chat is a genuine first message and counts, while a **catch-up** into an
/// empty chat is a cold backfill replaying the event log — counting it would
/// permanently inflate a people property that cannot be decremented.
enum Delivery: Equatable {
case live
case catchUp
}

/// The rate for a currency, native-per-USD, or nil when none is cached. Wired to
/// `RatesController` by `SessionContainer`; nil-returning by default so an
/// unwired reporter under-reports value rather than reporting it wrongly.
var usdRate: @MainActor (CurrencyCode) -> Rate? = { _ in nil }

private let selfUserID: UserID
private let increment: @MainActor (Analytics.ReceivedCounter, Double) -> Void
private let trackTipReceived: @MainActor (ConversationType?, ExchangedFiat) -> Void
private let trackMessageReceived: @MainActor (ConversationType?) -> Void

init(
selfUserID: UserID,
increment: @escaping @MainActor (Analytics.ReceivedCounter, Double) -> Void = { Analytics.increment($0, by: $1) },
trackTipReceived: @escaping @MainActor (ConversationType?, ExchangedFiat) -> Void = { Analytics.tipReceived(chatType: $0, exchangedFiat: $1) },
trackMessageReceived: @escaping @MainActor (ConversationType?) -> Void = { Analytics.messageReceived(chatType: $0) }
) {
self.selfUserID = selfUserID
self.increment = increment
self.trackTipReceived = trackTipReceived
self.trackMessageReceived = trackMessageReceived
}

// MARK: - Counters -

/// Credits the cumulative received counters for the inbound messages in `messages`
/// that sit above `countedThrough` — the newest message id already stored for the
/// conversation, read *before* this batch was persisted.
///
/// A tip increments both `Tips Received` and `Messages Received`: the message
/// counter is a total, not a non-tip remainder.
func countReceived(
_ messages: [ConversationMessage],
countedThrough: MessageID?,
delivery: Delivery
) {
// Nothing stored and nothing live: a cold backfill. Seed the watermark by
// letting the write land, but credit nothing.
if countedThrough == nil, delivery == .catchUp { return }

for message in messages {
guard isInbound(message) else { continue }
if let countedThrough, message.id <= countedThrough { continue }

increment(.messages, 1)

guard message.cashAction == .tipped, case .cash(let exchanged) = message.content else { continue }
increment(.tips, 1)

// Chat cash arrives in the SENDER's native currency. With no cached rate we
// count the tip but skip its value: an understated total is recoverable, a
// wrong one is permanent and unattributable.
guard let usd = usdValue(of: exchanged) else { continue }
increment(.tipsValue, usd)
}
}

// MARK: - Events -

/// Reports one event per inbound message in the window a read-pointer advance just
/// crossed. `Tip Received` and `Message Received` are mutually exclusive — a tip
/// reports only as a tip.
func reportRead(_ messages: [ConversationMessage], chatType: ConversationType?) {
for message in messages {
guard isInbound(message) else { continue }
if message.cashAction == .tipped, case .cash(let exchanged) = message.content {
trackTipReceived(chatType, exchanged)
} else {
trackMessageReceived(chatType)
}
}
}

// MARK: - Private -

/// Anything not sent by the signed-in user, matching Android: a system message
/// (no sender) is still something the user received.
private func isInbound(_ message: ConversationMessage) -> Bool {
!message.isFromSelf(selfUserID)
}

private func usdValue(of exchanged: ExchangedFiat) -> Double? {
let native = exchanged.nativeAmount
if native.currency == .usd { return native.doubleValue }
guard let rate = usdRate(native.currency) else { return nil }
return native.convertingToUSD(rate: rate).doubleValue
}
}
14 changes: 14 additions & 0 deletions Flipcash/Core/Controllers/Database/Database+Conversations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ nonisolated extension Database {
return try rows.map { conversationMessage(from: $0) }.compactMap { $0 }
}

/// Every message in the half-open id interval `(after, through]`, oldest-first — the window a
/// read-pointer advance just crossed. A nil `after` means the pointer had never been set, so the
/// whole stored history up to `through` counts as newly read. Index-backed by the composite
/// `(conversationId, id)` primary key.
func messages(conversationID: ConversationID, after: MessageID?, through: MessageID) throws -> [ConversationMessage] {
let m = ConversationMessageTable()
var query = m.table.filter(m.conversationId == conversationID.data && m.id <= through.value)
if let after {
query = query.filter(m.id > after.value)
}
let rows = try reader.prepareRowIterator(query.order(m.id.asc))
return try rows.map { conversationMessage(from: $0) }.compactMap { $0 }
}

/// The id `step` rows older than `before` — the next anchor when the reader pages back — falling
/// back to the oldest available older row; nil when nothing older is persisted.
func olderAnchor(conversationID: ConversationID, before: UInt64, step: Int) throws -> UInt64? {
Expand Down
9 changes: 9 additions & 0 deletions Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ struct ProfileNameScreen: View {
private func submit() {
guard let name = state.validatedDisplayName, !isSubmitting else { return }

// Read before the RPC: `updateProfile()` below installs the new name, after
// which every submission would look like a replacement.
let hadPreviousName = !(sessionContainer.session.profile?.displayName ?? "").isEmpty
let source: Analytics.DisplayNameSource = switch completion {
case .tipcard: .tipCardSetup
case .back: .myAccount
}

submitTask = Task {
defer { submitTask = nil }

Expand All @@ -104,6 +112,7 @@ struct ProfileNameScreen: View {
name,
owner: sessionContainer.session.ownerKeyPair
)
Analytics.displayNameSubmitted(source: source, hadPreviousName: hadPreviousName)
try await sessionContainer.session.updateProfile()

guard !Task.isCancelled else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ final class OnboardingNameViewModel {

do {
try await flipClient.setDisplayName(name, owner: owner)
// Onboarding runs pre-login against a brand-new account, so there is
// never a prior name here — this is always a first set.
Analytics.displayNameSubmitted(source: .onboarding, hadPreviousName: false)
onComplete?()

} catch ErrorProfile.moderated(let category) {
Expand Down
10 changes: 6 additions & 4 deletions Flipcash/Core/Screens/Send/SendAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,10 @@ final class SendAmountViewModel {

// 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.
let transferEvent: Analytics.TransferEvent = if case .tip = target { .sentTip } else { .sentCash }
// 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

do {
try await sender.send(
Expand All @@ -207,10 +209,10 @@ final class SendAmountViewModel {
to: recipient,
chat: chatPaymentMetadata()
)
Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: true, error: nil)
Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: true, error: nil, origin: tipOrigin)
return .success
} catch {
Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: false, error: error)
Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: false, error: error, origin: tipOrigin)
showSendError()
return .failed
}
Expand Down
14 changes: 14 additions & 0 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,15 @@ final class SessionAuthenticator {

Analytics.setIdentity(initializedAccount.userID)

// Mint metadata lives in the session's local cache, so the symbol lookup can
// only be wired once a session exists. Weak: the resolver outlives a logout,
// and a stale session must not be kept alive by an analytics closure.
let sessionModel = session.session
Analytics.tokenSymbolResolver = { [weak sessionModel] base58 in
guard let mint = try? PublicKey(base58: base58) else { return nil }
return sessionModel?.storedMintMetadata(for: mint)?.symbol
}

Task { await checkForUnusableAccount() }
}

Expand Down Expand Up @@ -554,6 +563,11 @@ final class SessionContainer {
owner: session.ownerKeyPair,
selfUserID: session.userID
)
// Chat cash arrives in the sender's native currency; the counters are USD.
// Wired before `start()` so the first delivered message already normalises.
conversationController.receipts.usdRate = { [weak ratesController] currency in
ratesController?.rate(for: currency)
}
conversationController.start()
self.conversationController = conversationController

Expand Down
Loading