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
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,7 @@
<string name="label_linkCard_cash">Cash</string>
<string name="label_linkCard_claim">Tap to claim</string>
<string name="label_linkCard_claimed">Claimed</string>
<string name="label_linkCard_claiming">Claiming…</string>
<string name="label_linkCard_expired">Expired</string>
<!-- Group invite link card. The untitled name is provisional, pending copy. -->
<string name="label_linkCard_untitledGroup">Group Chat</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1332,6 +1332,12 @@ internal class ChatViewModel @Inject constructor(
}
.launchIn(viewModelScope)

// The voucher being claimed reads as claiming until the claim settles, rather than offering
// the claim again for a re-tap to start a second one.
cashLinkClaims.claimInFlight
.onEach { linkCardResolver.markClaiming(it) }
.launchIn(viewModelScope)

// STARTED rather than a timer of its own, which makes one construct cover both cases worth
// covering: the first pass runs on every foreground edge, so a reader who put the phone
// down and came back gets a fresh answer immediately, and the loop then carries the case
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,30 @@ internal class LinkCardResolver(
private val _revision = MutableStateFlow(0)
override val revision: StateFlow<Int> = _revision.asStateFlow()

/** The entropy of this device's claim in flight, drawn as [LinkCard.Cash.Claim.Claiming]. */
@Volatile
private var claiming: String? = null

/**
* Draws [entropy]'s voucher as [LinkCard.Cash.Claim.Claiming] while its claim runs, or clears
* that with null. Only an overlay: the stored answer stays what the lookup said, so nothing
* here can outlive the claim or be mistaken for the server's view of it.
*/
fun markClaiming(entropy: String?) {
if (claiming == entropy) return
claiming = entropy
_revision.update { it + 1 }
}

override fun peek(card: LinkCard): LinkCard? = when (card) {
is LinkCard.Cash -> cashAnswers[card.entropy]?.let { card.copy(state = it) }
is LinkCard.Cash -> cashAnswers[card.entropy]?.let { card.copy(state = it.overlayClaiming(card.entropy)) }
is LinkCard.TokenInfo -> tokenAnswers[card.mint]?.let { card.copy(state = it) }
is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) }
is LinkCard.User -> userAnswers[card.identity]?.let { card.copy(state = it) }
}

override suspend fun resolve(card: LinkCard): LinkCard = when (card) {
is LinkCard.Cash -> card.copy(state = cashState(card.entropy))
is LinkCard.Cash -> card.copy(state = cashState(card.entropy).overlayClaiming(card.entropy))
is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint))
is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId))
is LinkCard.User -> card.copy(state = userState(card.identity))
Expand Down Expand Up @@ -172,6 +187,13 @@ internal class LinkCardResolver(
}

/** Ends the queries with the screen that asked for them. */
private fun LinkCard.Cash.State.overlayClaiming(entropy: String): LinkCard.Cash.State =
if (this is LinkCard.Cash.State.Resolved && claim == LinkCard.Cash.Claim.Claimable && entropy == claiming) {
copy(claim = LinkCard.Cash.Claim.Claiming)
} else {
this
}

fun dispose() {
scope.cancel()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ class ChatGroupAnalyticsTest {
private val userFlags = mockk<UserFlagsCoordinator>(relaxed = true)
private val linkCardClassifier = mockk<LinkCardClassifier>(relaxed = true)
private val linkCardResolver = mockk<LinkCardResolver>(relaxed = true)
private val cashLinkClaims = mockk<CashLinkClaims>(relaxed = true)
private val cashLinkClaims = mockk<CashLinkClaims>(relaxed = true) {
every { claimInFlight } returns MutableStateFlow(null)
}
private val chatDraftStore = mockk<ChatDraftStore>(relaxed = true)
private val recentReactionsStore = mockk<RecentReactionsStore>(relaxed = true)
private val emojiCatalogLoader = mockk<EmojiCatalogLoader>(relaxed = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ class ChatGroupCashLinkTest {
private val userFlags = mockk<UserFlagsCoordinator>(relaxed = true)
private val linkCardClassifier = mockk<LinkCardClassifier>(relaxed = true)
private val linkCardResolver = mockk<LinkCardResolver>(relaxed = true)
private val cashLinkClaims = mockk<CashLinkClaims>(relaxed = true)
private val cashLinkClaims = mockk<CashLinkClaims>(relaxed = true) {
every { claimInFlight } returns MutableStateFlow(null)
}
private val chatCashLinks = mockk<ChatCashLinks>(relaxed = true)
private val chatDraftStore = mockk<ChatDraftStore>(relaxed = true)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class ChatMentionPickerTest {
userFlags = mockk(relaxed = true),
linkCardClassifier = mockk(relaxed = true),
linkCardResolver = mockk(relaxed = true),
cashLinkClaims = mockk(relaxed = true),
cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) },
chatCashLinks = mockk(relaxed = true),
chatDraftStore = mockk(relaxed = true),
recentReactionsStore = mockk(relaxed = true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class ChatOpenTranscriptTest {
userFlags = mockk(relaxed = true),
linkCardClassifier = mockk(relaxed = true),
linkCardResolver = mockk(relaxed = true),
cashLinkClaims = mockk(relaxed = true),
cashLinkClaims = mockk(relaxed = true) { every { claimInFlight } returns MutableStateFlow(null) },
chatCashLinks = mockk(relaxed = true),
chatDraftStore = mockk(relaxed = true),
recentReactionsStore = mockk(relaxed = true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ class ChatSendFailureAnalyticsTest {
private val userFlags = mockk<UserFlagsCoordinator>(relaxed = true)
private val linkCardClassifier = mockk<LinkCardClassifier>(relaxed = true)
private val linkCardResolver = mockk<LinkCardResolver>(relaxed = true)
private val cashLinkClaims = mockk<CashLinkClaims>(relaxed = true)
private val cashLinkClaims = mockk<CashLinkClaims>(relaxed = true) {
every { claimInFlight } returns MutableStateFlow(null)
}
private val chatDraftStore = mockk<ChatDraftStore>(relaxed = true)
private val recentReactionsStore = mockk<RecentReactionsStore>(relaxed = true)
private val emojiCatalogLoader = mockk<EmojiCatalogLoader>(relaxed = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,45 @@ class LinkCardResolverTest {
assertEquals("$15.00", state.amount)
}

@Test
fun `a claimable voucher reads as claiming while its claim is in flight`() = runTest {
val resolver = LinkCardResolver(
scope = backgroundScope,
giftCard = { Result.success(snapshot()) },
tokenMetadata = { Result.success(mock<Token>()) },
group = { Result.failure(IllegalStateException("unused")) },
user = { Result.failure(IllegalStateException("unused")) },
)
resolver.resolve(card)

val before = resolver.revision.value
resolver.markClaiming(card.entropy)

assertTrue(resolver.revision.value > before)
assertEquals(LinkCard.Cash.Claim.Claiming, (resolver.peek(card) as LinkCard.Cash).claim())
assertEquals(LinkCard.Cash.Claim.Claiming, (resolver.resolve(card) as LinkCard.Cash).claim())

resolver.markClaiming(null)
assertEquals(LinkCard.Cash.Claim.Claimable, (resolver.peek(card) as LinkCard.Cash).claim())
}

@Test
fun `a claim in flight for another link leaves this voucher claimable`() = runTest {
val resolver = LinkCardResolver(
scope = backgroundScope,
giftCard = { Result.success(snapshot()) },
tokenMetadata = { Result.success(mock<Token>()) },
group = { Result.failure(IllegalStateException("unused")) },
user = { Result.failure(IllegalStateException("unused")) },
)
resolver.resolve(card)
resolver.markClaiming("someOtherEntropy")

assertEquals(LinkCard.Cash.Claim.Claimable, (resolver.peek(card) as LinkCard.Cash).claim())
}

private fun LinkCard.Cash.claim() = (state as LinkCard.Cash.State.Resolved).claim

@Test
fun `a resolved link is only looked up once`() = runTest {
var calls = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ sealed interface LinkCard {
) : State
}

enum class Claim { Claimable, Claimed, Expired }
/**
* [Claiming] is never a lookup's answer. It is drawn over a [Claimable] voucher while this
* device's claim of it is running, so the card stops offering a claim that is already
* underway, and it goes back to whatever the lookup says once the claim settles.
*/
enum class Claim { Claimable, Claiming, Claimed, Expired }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ private fun CashLinkCard(
// Off the paper: the stub this used to sit on left with whoever claimed it.
StubLabel(stringResource(R.string.label_linkCard_claimed), onPaper = false)
LinkCard.Cash.Claim.Expired -> StubLabel(stringResource(R.string.label_linkCard_expired))
// A label rather than the pill: the claim is already running, so there is nothing
// left to offer, and the tap that would offer it again is ignored.
LinkCard.Cash.Claim.Claiming -> StubLabel(stringResource(R.string.label_linkCard_claiming))
// The same voucher whoever is reading it. The transcript already says who sent the
// link -- the bubble sits on the sender's side -- so a card that read differently for
// the issuer would be saying it twice, and saying it in the one place both people are
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,10 @@ private fun rememberLinkCardClick(): (LinkCard) -> Unit {
return { card ->
when (card) {
// The chat opens it, because only the chat knows whether this reader may collect it.
is LinkCard.Cash -> actionHandler(ChatAction.CashLinkOpened(card.entropy, card.url))
// A voucher already being claimed would only start the same claim again.
is LinkCard.Cash -> if ((card.state as? LinkCard.Cash.State.Resolved)?.claim != LinkCard.Cash.Claim.Claiming) {
actionHandler(ChatAction.CashLinkOpened(card.entropy, card.url))
}
is LinkCard.TokenInfo -> actionHandler(ChatAction.ViewToken(card.mint))
// Pushed over this chat rather than through the chat deep link, which replaces the
// stack: Back has to return to the message that held the invite.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ data class SettledClaim(
*/
interface CashLinkClaims {
val settledClaims: Flow<SettledClaim>

/**
* The entropy of the claim running now, or null. Claims run one at a time, so a surface can
* show this link as claiming and ignore taps on it until [settledClaims] names it.
*/
val claimInFlight: StateFlow<String?>
}

/** One-shot signals from tip card resolution that only the UI can act on. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.consumeAsFlow
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -75,9 +77,14 @@ class CashLinkDelegate @Inject constructor(
override val settledClaims: Flow<SettledClaim> = _settledClaims.asSharedFlow()

private val giftCardClaimInProgress = MutableStateFlow<String?>(null)
override val claimInFlight: StateFlow<String?> = giftCardClaimInProgress.asStateFlow()

override fun openCashLink(cashLink: String?) {
BottomBarManager.clear()
// A link opened while a claim runs is dropped below, so it leaves whatever is on screen
// alone rather than dismissing an alert for nothing.
if (giftCardClaimInProgress.value == null) {
BottomBarManager.clear()
}

val entropy = cashLink?.trim()?.replace("\n", "")
if (entropy == null) {
Expand Down Expand Up @@ -126,18 +133,23 @@ class CashLinkDelegate @Inject constructor(
return
}

if (giftCardClaimInProgress.value == null) {
giftCardClaimInProgress.value = entropy
if (claimGiftCard(owner = owner, entropy = entropy, claimIfOwned = false)) {
analytics.track(DeeplinkEvents.routed(DeeplinkType.CashLink().analytics, error = null))
claimGiftCard(owner = owner, entropy = entropy, claimIfOwned = false)
}
}

/**
* Starts a claim of [entropy] unless one is already running, and returns whether it started.
* Every way into a claim holds the slot, including the reclaim from the own-cash prompt, which
* runs after the first attempt has already cleared it.
*/
private fun claimGiftCard(
owner: AccountCluster,
entropy: String,
claimIfOwned: Boolean
) {
): Boolean {
if (!giftCardClaimInProgress.compareAndSet(null, entropy)) return false

trace(
tag = "Session",
message = "Claiming gift card: $entropy",
Expand All @@ -150,8 +162,10 @@ class CashLinkDelegate @Inject constructor(
claimIfOwned = claimIfOwned,
onReceived = { token, amount ->
tokenCoordinator.add(token, amount)
giftCardClaimInProgress.value = null
// Settled before the slot clears, so a listener redrawing on both learns the claim
// landed before it would draw the link as claimable again.
_settledClaims.tryEmit(SettledClaim(entropy, collected = true))
giftCardClaimInProgress.value = null
analytics.track(TransferEvents.receiveCashLink(State.SUCCESS, amount.analytics, null))
val bill = Scannable.Payable.forToken(
amount = amount,
Expand All @@ -163,8 +177,8 @@ class CashLinkDelegate @Inject constructor(
_events.trySend(Event.RefreshFeed)
},
onError = { cause ->
giftCardClaimInProgress.value = null
_settledClaims.tryEmit(SettledClaim(entropy, collected = false))
giftCardClaimInProgress.value = null
if (cause !is ReceiveGiftTransactorError.UsersGiftCard) {
analytics.track(
TransferEvents.receiveCashLink(State.FAILURE, null, cause.analytics)
Expand Down Expand Up @@ -225,5 +239,6 @@ class CashLinkDelegate @Inject constructor(
}
}
)
return true
}
}
Loading
Loading