Skip to content

Commit ac2792f

Browse files
authored
fix(chat): gate cash-card taps on membership and the thank-you on the composer (#1545)
A non-member reading a group could tap a cash card and claim it, and any reader was recorded for a "Thanks for the cash!" reply even when they had no composer to post it from, so the reply went to a send the server rejects. The card now hands its URL to the chat instead of opening it, and State.cashCardTap decides: a viewer outside the group gets a "Join to Collect" sheet and the link stays closed; a member opens it, and the tap is recorded for a reply only when the composer is live. Mirrors iOS (code-ios-app feat/cash-link-claim-reply).
1 parent dce635a commit ac2792f

7 files changed

Lines changed: 152 additions & 20 deletions

File tree

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@
182182
<string name="message_invite_contact">You should download Flipcash, a new way to send cash\n\n%1$s</string>
183183
<!-- Posted by the claimer's own device as a reply to the voucher they just collected in a chat. -->
184184
<string name="message_cash_link_thanks">Thanks for the cash!</string>
185+
<!-- Shown when a non-member reading a group taps a cash link in it. The link is not opened. -->
186+
<string name="title_joinToCollect">Join to Collect</string>
187+
<string name="description_joinToCollect">Join this chat to collect cash sent in it.</string>
185188

186189
<string name="title_enterAddress">Enter Solana address</string>
187190
<!-- v2 app-bar title for the withdrawal destination step; title_enterAddress stays the field placeholder. -->

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.flipcash.app.core.tokens.isReserve
2727
import com.flipcash.app.core.ui.ConfirmationStyle
2828
import com.flipcash.app.core.util.Linkify
2929
import com.flipcash.app.funding.PurchaseMethodController
30+
import com.flipcash.app.messenger.internal.link.CashCardTap
3031
import com.flipcash.app.messenger.internal.link.ClaimReplyTargets
3132
import com.flipcash.app.messenger.internal.link.LinkCardClassifier
3233
import com.flipcash.app.messenger.internal.link.LinkCardResolver
@@ -378,6 +379,27 @@ internal class ChatViewModel @Inject constructor(
378379
val replacesComposer: Boolean
379380
get() = joinProgress.success || isOutsideGroup
380381

382+
/**
383+
* What a tap on a cash card in this transcript does.
384+
*
385+
* Only a member collects. An eligible non-member reads the transcript sharp and so can see
386+
* and tap a card, but the cash was sent to the group, so the card tells them to join
387+
* instead. A blurred transcript has nothing to tap; it falls on the same side because it is
388+
* also outside the group.
389+
*
390+
* A member always collects, but the claim is only answered with a thank-you when the
391+
* composer is live, because the thank-you is a message the viewer posts. A deactivated DM
392+
* has no composer, and a reply from it would be refused.
393+
*
394+
* Mirrors iOS, where the tap is refused at the gate's `.join` and recorded for a reply only
395+
* at `.open`.
396+
*/
397+
val cashCardTap: CashCardTap
398+
get() = when {
399+
isOutsideGroup -> CashCardTap.JoinToCollect
400+
else -> CashCardTap.Collect(thanks = !isAnonymous && !replacesComposer)
401+
}
402+
381403
/**
382404
* The link that invites someone into this group, or `null` when there is nobody to invite:
383405
* a DM, or a group this viewer has not joined.
@@ -537,11 +559,18 @@ internal class ChatViewModel @Inject constructor(
537559
/**
538560
* The reader tapped a cash voucher in this transcript, naming the link.
539561
*
540-
* Only the name: the tap has already left through the URL handler by the time this
541-
* arrives, and nothing here claims anything. See [initClaimReplies].
562+
* Only the name: the screen opens the link through the URL handler right after
563+
* dispatching this, and nothing here claims anything. Dispatched only when
564+
* [State.cashCardTap] lets the reader collect. See [initClaimReplies].
542565
*/
543566
data class CashLinkOpened(val entropy: String) : Event
544567

568+
/**
569+
* The reader tapped a cash voucher they cannot collect from here, per
570+
* [State.cashCardTap]. The link was not opened; this only tells them why.
571+
*/
572+
data object CashLinkRefused : Event
573+
545574
/** Asks the transcript to scroll to [messageId] — a tap on a quote. */
546575
data class JumpToMessage(val messageId: Long) : Event
547576

@@ -1009,9 +1038,23 @@ internal class ChatViewModel @Inject constructor(
10091038
*/
10101039
private fun initClaimReplies() {
10111040
eventFlow.filterIsInstance<Event.CashLinkOpened>()
1012-
.onEach { claimReplyTargets.tapped(it.entropy) }
1041+
.onEach { event ->
1042+
// Not recorded for a viewer who cannot post: nothing would come of the thank-you
1043+
// but a send the server refuses.
1044+
val tap = stateFlow.value.cashCardTap
1045+
if (tap is CashCardTap.Collect && tap.thanks) claimReplyTargets.tapped(event.entropy)
1046+
}
10131047
.flowOn(Dispatchers.Main.immediate)
10141048
.launchIn(viewModelScope)
1049+
1050+
eventFlow.filterIsInstance<Event.CashLinkRefused>()
1051+
.onEach {
1052+
BottomBarManager.showInfo(
1053+
title = resources.getString(R.string.title_joinToCollect),
1054+
message = resources.getString(R.string.description_joinToCollect),
1055+
)
1056+
}
1057+
.launchIn(viewModelScope)
10151058
}
10161059

10171060
/**
@@ -2334,6 +2377,7 @@ internal class ChatViewModel @Inject constructor(
23342377
// Nothing on screen moves when a voucher is tapped -- the link leaves, the card
23352378
// keeps saying what it said, and the claim comes back as its own signal.
23362379
is Event.CashLinkOpened -> { state -> state }
2380+
Event.CashLinkRefused -> { state -> state }
23372381
// The request itself changes nothing: the target is only worth holding once the
23382382
// walk's bound resolves, and that read is what decides whether it can be reached.
23392383
is Event.JumpToMessage -> { state -> state }
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.flipcash.app.messenger.internal.link
2+
3+
/**
4+
* What a tap on a cash card does in the chat on screen. See `ChatViewModel.State.cashCardTap`.
5+
*
6+
* Decided before the link leaves, because once it has left nothing can call it back: the claim
7+
* path takes an entropy from anywhere and does not know a transcript was involved.
8+
*/
9+
internal sealed interface CashCardTap {
10+
11+
/**
12+
* The viewer is reading the group from outside. The link is not opened and the viewer is told
13+
* to join first.
14+
*
15+
* A client-side guard on the card only. The URL is still in the message text, and whoever copies
16+
* it out can claim it wherever they paste it.
17+
*/
18+
data object JoinToCollect : CashCardTap
19+
20+
/**
21+
* The link opens and the claim runs as it would from anywhere else.
22+
*
23+
* [thanks] is whether a collected claim is answered with a reply on the transcript, which is
24+
* only when the viewer can post one.
25+
*/
26+
data class Collect(val thanks: Boolean) : CashCardTap
27+
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
99
import androidx.compose.runtime.remember
1010
import androidx.compose.runtime.setValue
1111
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.platform.LocalUriHandler
1213
import androidx.compose.ui.platform.testTag
1314
import androidx.compose.ui.unit.dp
1415
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -19,6 +20,7 @@ import com.flipcash.app.core.chat.ChatStep
1920
import com.flipcash.app.core.tokens.TokenInfoEntry
2021
import com.flipcash.app.messenger.internal.ChatSubject
2122
import com.flipcash.app.messenger.internal.ChatViewModel
23+
import com.flipcash.app.messenger.internal.link.CashCardTap
2224
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
2325
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge
2426
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge
@@ -39,6 +41,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
3941
val messages = viewModel.messages.collectAsLazyPagingItems()
4042
val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle(null)
4143
val navigator = LocalCodeNavigator.current
44+
val uriHandler = LocalUriHandler.current
4245

4346
val hazeState = rememberHazeState()
4447
// Measured by the bar and read by the transcript: the blur has to cover the bar's own
@@ -122,8 +125,14 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
122125
viewModel.dispatchEvent(ChatViewModel.Event.CancelReply)
123126
}
124127

125-
is ChatAction.CashLinkOpened -> {
126-
viewModel.dispatchEvent(ChatViewModel.Event.CashLinkOpened(action.entropy))
128+
is ChatAction.CashLinkOpened -> when (state.cashCardTap) {
129+
CashCardTap.JoinToCollect -> viewModel.dispatchEvent(ChatViewModel.Event.CashLinkRefused)
130+
// Reported before the link leaves, so the tap is on record by the time the claim
131+
// can come back.
132+
is CashCardTap.Collect -> {
133+
viewModel.dispatchEvent(ChatViewModel.Event.CashLinkOpened(action.entropy))
134+
uriHandler.openUri(action.url)
135+
}
127136
}
128137

129138
is ChatAction.JumpToMessage -> {

‎apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal
33
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
44
import com.flipcash.app.core.chat.ChatParticipant
55
import com.flipcash.app.core.contacts.DeviceContact
6+
import com.flipcash.app.messenger.internal.link.CashCardTap
67
import com.flipcash.services.models.UserProfile
78
import com.flipcash.services.models.chat.ChatId
89
import com.flipcash.services.models.chat.ChatRuleRequirement
@@ -459,6 +460,59 @@ class ChatViewModelStateTest {
459460
assertFalse(ChatViewModel.State().sendCashReady)
460461
}
461462

463+
@Test
464+
fun `a member collects a cash card and thanks the sender`() {
465+
val state = ChatViewModel.State(
466+
subject = group(isMember = true),
467+
groupAccess = GroupAccess.Membered,
468+
)
469+
assertEquals(CashCardTap.Collect(thanks = true), state.cashCardTap)
470+
assertEquals(
471+
CashCardTap.Collect(thanks = true),
472+
ChatViewModel.State(subject = dm).cashCardTap,
473+
)
474+
}
475+
476+
@Test
477+
fun `an eligible non-member is told to join rather than collecting`() {
478+
// The one reader outside the group who can see a card to tap. Whatever the card does must
479+
// not open the link, so the claim path is never reached from here.
480+
val state = ChatViewModel.State(
481+
subject = group(isMember = false),
482+
groupAccess = GroupAccess.Eligible,
483+
)
484+
assertTrue(state.readsFromOutside)
485+
assertEquals(CashCardTap.JoinToCollect, state.cashCardTap)
486+
}
487+
488+
@Test
489+
fun `every viewer outside the group is told to join`() {
490+
// A blurred transcript has nothing to tap, but the answer must not depend on that.
491+
for (state in listOf(
492+
ChatViewModel.State(subject = group(isMember = false), groupAccess = GroupAccess.Blocked(unmet)),
493+
ChatViewModel.State(subject = group(isMember = false), groupAccess = null),
494+
ChatViewModel.State(subject = group(isMember = null), groupAccess = GroupAccess.Eligible),
495+
)) {
496+
assertEquals(CashCardTap.JoinToCollect, state.cashCardTap)
497+
}
498+
}
499+
500+
@Test
501+
fun `a viewer with no composer collects without a thank-you`() {
502+
// The thank-you is a message the viewer posts, so a viewer who cannot post is never
503+
// recorded for one, and the claim never reaches a send the server would refuse.
504+
val deactivatedDm = ChatViewModel.State(subject = dm, isAnonymous = true)
505+
assertEquals(CashCardTap.Collect(thanks = false), deactivatedDm.cashCardTap)
506+
507+
// The join's checkmark still holds the composer back for its beat.
508+
val justJoined = ChatViewModel.State(
509+
subject = group(isMember = true),
510+
groupAccess = GroupAccess.Membered,
511+
joinProgress = LoadingSuccessState(success = true),
512+
)
513+
assertEquals(CashCardTap.Collect(thanks = false), justJoined.cashCardTap)
514+
}
515+
462516
/** What the ViewModel's own `draftSnapshot` composes, over a state the reducer produced. */
463517
private fun ChatViewModel.State.draftSnapshot(): ChatDraftSnapshot = chatDraftOf(
464518
composerText = chatInputState.text.toString(),

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,15 @@ sealed interface ChatAction {
6969
data object CancelReply : ChatAction
7070

7171
/**
72-
* The reader tapped a cash voucher, naming the link they are about to open.
72+
* The reader tapped a cash voucher.
7373
*
74-
* Not a request to claim it, and not what opens it: the tap still leaves through the URL
74+
* Not a request to claim it. The handler decides whether [url] opens at all — a non-member
75+
* reading a group is told to join first — and when it does, the link leaves through the URL
7576
* handler and comes back as a deeplink, which is what keeps the card path off the claim path.
76-
* This is the chat saying which link left from here, because that is the one thing lost at
77+
* [entropy] is the chat noting which link left from here, because that is the one thing lost at
7778
* that boundary — a claim arrives back knowing an entropy and nothing about a transcript.
7879
*/
79-
data class CashLinkOpened(val entropy: String) : ChatAction
80+
data class CashLinkOpened(val entropy: String, val url: String) : ChatAction
8081
}
8182

8283
typealias ChatActionHandler = (ChatAction) -> Unit

‎apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ import androidx.compose.ui.platform.LocalDensity
3939
import androidx.compose.ui.platform.LocalInspectionMode
4040
import androidx.compose.ui.platform.testTag
4141
import androidx.compose.ui.res.stringResource
42-
import androidx.compose.ui.platform.LocalUriHandler
4342
import androidx.compose.ui.text.AnnotatedString
4443
import androidx.compose.ui.text.Placeholder
4544
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -572,8 +571,9 @@ private fun BareLinkCard(
572571
/**
573572
* What a tap on a card does.
574573
*
575-
* A cash link goes back out through the URL handler its link span used, so replacing the text with
576-
* a card changed how the message looks and not what tapping it does.
574+
* A cash link is handed to the chat, which opens it through the URL handler its link span used
575+
* unless the reader cannot collect from where they are — a non-member reading a group. So the card
576+
* changed how the message looks and not what tapping it does, except for that reader.
577577
*
578578
* A token link does not. Its URL classifies as a deep link, and the router answers that with the
579579
* wallet sheet plus the token's card expanded in place — right for a link arriving from outside the
@@ -585,16 +585,10 @@ private fun BareLinkCard(
585585
@Composable
586586
private fun rememberLinkCardClick(): (LinkCard) -> Unit {
587587
val actionHandler = LocalChatActionHandler.current
588-
val uriHandler = LocalUriHandler.current
589588
return { card ->
590589
when (card) {
591-
// The entropy is reported and the link still leaves through the URL handler, in that
592-
// order and unconditionally. Nothing here waits on the report or reads it back, so a
593-
// tap opens the link whatever the transcript does with the name.
594-
is LinkCard.Cash -> {
595-
actionHandler(ChatAction.CashLinkOpened(card.entropy))
596-
uriHandler.openUri(card.url)
597-
}
590+
// The chat opens it, because only the chat knows whether this reader may collect it.
591+
is LinkCard.Cash -> actionHandler(ChatAction.CashLinkOpened(card.entropy, card.url))
598592
is LinkCard.TokenInfo -> actionHandler(ChatAction.ViewToken(card.mint))
599593
// Pushed over this chat rather than through the chat deep link, which replaces the
600594
// stack: Back has to return to the message that held the invite.

0 commit comments

Comments
 (0)