diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 09e4e44abf..37db268648 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -182,6 +182,9 @@
You should download Flipcash, a new way to send cash\n\n%1$s
Thanks for the cash!
+
+ Join to Collect
+ Join this chat to collect cash sent in it.
Enter Solana address
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index 58d0b1fe7f..c281ba160e 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
@@ -27,6 +27,7 @@ import com.flipcash.app.core.tokens.isReserve
import com.flipcash.app.core.ui.ConfirmationStyle
import com.flipcash.app.core.util.Linkify
import com.flipcash.app.funding.PurchaseMethodController
+import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.app.messenger.internal.link.ClaimReplyTargets
import com.flipcash.app.messenger.internal.link.LinkCardClassifier
import com.flipcash.app.messenger.internal.link.LinkCardResolver
@@ -378,6 +379,27 @@ internal class ChatViewModel @Inject constructor(
val replacesComposer: Boolean
get() = joinProgress.success || isOutsideGroup
+ /**
+ * What a tap on a cash card in this transcript does.
+ *
+ * Only a member collects. An eligible non-member reads the transcript sharp and so can see
+ * and tap a card, but the cash was sent to the group, so the card tells them to join
+ * instead. A blurred transcript has nothing to tap; it falls on the same side because it is
+ * also outside the group.
+ *
+ * A member always collects, but the claim is only answered with a thank-you when the
+ * composer is live, because the thank-you is a message the viewer posts. A deactivated DM
+ * has no composer, and a reply from it would be refused.
+ *
+ * Mirrors iOS, where the tap is refused at the gate's `.join` and recorded for a reply only
+ * at `.open`.
+ */
+ val cashCardTap: CashCardTap
+ get() = when {
+ isOutsideGroup -> CashCardTap.JoinToCollect
+ else -> CashCardTap.Collect(thanks = !isAnonymous && !replacesComposer)
+ }
+
/**
* The link that invites someone into this group, or `null` when there is nobody to invite:
* a DM, or a group this viewer has not joined.
@@ -537,11 +559,18 @@ internal class ChatViewModel @Inject constructor(
/**
* The reader tapped a cash voucher in this transcript, naming the link.
*
- * Only the name: the tap has already left through the URL handler by the time this
- * arrives, and nothing here claims anything. See [initClaimReplies].
+ * Only the name: the screen opens the link through the URL handler right after
+ * dispatching this, and nothing here claims anything. Dispatched only when
+ * [State.cashCardTap] lets the reader collect. See [initClaimReplies].
*/
data class CashLinkOpened(val entropy: String) : Event
+ /**
+ * The reader tapped a cash voucher they cannot collect from here, per
+ * [State.cashCardTap]. The link was not opened; this only tells them why.
+ */
+ data object CashLinkRefused : Event
+
/** Asks the transcript to scroll to [messageId] — a tap on a quote. */
data class JumpToMessage(val messageId: Long) : Event
@@ -1009,9 +1038,23 @@ internal class ChatViewModel @Inject constructor(
*/
private fun initClaimReplies() {
eventFlow.filterIsInstance()
- .onEach { claimReplyTargets.tapped(it.entropy) }
+ .onEach { event ->
+ // Not recorded for a viewer who cannot post: nothing would come of the thank-you
+ // but a send the server refuses.
+ val tap = stateFlow.value.cashCardTap
+ if (tap is CashCardTap.Collect && tap.thanks) claimReplyTargets.tapped(event.entropy)
+ }
.flowOn(Dispatchers.Main.immediate)
.launchIn(viewModelScope)
+
+ eventFlow.filterIsInstance()
+ .onEach {
+ BottomBarManager.showInfo(
+ title = resources.getString(R.string.title_joinToCollect),
+ message = resources.getString(R.string.description_joinToCollect),
+ )
+ }
+ .launchIn(viewModelScope)
}
/**
@@ -2334,6 +2377,7 @@ internal class ChatViewModel @Inject constructor(
// Nothing on screen moves when a voucher is tapped -- the link leaves, the card
// keeps saying what it said, and the claim comes back as its own signal.
is Event.CashLinkOpened -> { state -> state }
+ Event.CashLinkRefused -> { state -> state }
// The request itself changes nothing: the target is only worth holding once the
// walk's bound resolves, and that read is what decides whether it can be reached.
is Event.JumpToMessage -> { state -> state }
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CashCardTap.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CashCardTap.kt
new file mode 100644
index 0000000000..f19ae219c9
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CashCardTap.kt
@@ -0,0 +1,27 @@
+package com.flipcash.app.messenger.internal.link
+
+/**
+ * What a tap on a cash card does in the chat on screen. See `ChatViewModel.State.cashCardTap`.
+ *
+ * Decided before the link leaves, because once it has left nothing can call it back: the claim
+ * path takes an entropy from anywhere and does not know a transcript was involved.
+ */
+internal sealed interface CashCardTap {
+
+ /**
+ * The viewer is reading the group from outside. The link is not opened and the viewer is told
+ * to join first.
+ *
+ * A client-side guard on the card only. The URL is still in the message text, and whoever copies
+ * it out can claim it wherever they paste it.
+ */
+ data object JoinToCollect : CashCardTap
+
+ /**
+ * The link opens and the claim runs as it would from anywhere else.
+ *
+ * [thanks] is whether a collected claim is answered with a reply on the transcript, which is
+ * only when the viewer can post one.
+ */
+ data class Collect(val thanks: Boolean) : CashCardTap
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
index 2086ea5077..648b74aaf2 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -19,6 +20,7 @@ import com.flipcash.app.core.chat.ChatStep
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
+import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge
@@ -39,6 +41,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
val messages = viewModel.messages.collectAsLazyPagingItems()
val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle(null)
val navigator = LocalCodeNavigator.current
+ val uriHandler = LocalUriHandler.current
val hazeState = rememberHazeState()
// 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) {
viewModel.dispatchEvent(ChatViewModel.Event.CancelReply)
}
- is ChatAction.CashLinkOpened -> {
- viewModel.dispatchEvent(ChatViewModel.Event.CashLinkOpened(action.entropy))
+ is ChatAction.CashLinkOpened -> when (state.cashCardTap) {
+ CashCardTap.JoinToCollect -> viewModel.dispatchEvent(ChatViewModel.Event.CashLinkRefused)
+ // Reported before the link leaves, so the tap is on record by the time the claim
+ // can come back.
+ is CashCardTap.Collect -> {
+ viewModel.dispatchEvent(ChatViewModel.Event.CashLinkOpened(action.entropy))
+ uriHandler.openUri(action.url)
+ }
}
is ChatAction.JumpToMessage -> {
diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt
index 2a5841cd60..20f1d048e5 100644
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt
@@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.contacts.DeviceContact
+import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatRuleRequirement
@@ -459,6 +460,59 @@ class ChatViewModelStateTest {
assertFalse(ChatViewModel.State().sendCashReady)
}
+ @Test
+ fun `a member collects a cash card and thanks the sender`() {
+ val state = ChatViewModel.State(
+ subject = group(isMember = true),
+ groupAccess = GroupAccess.Membered,
+ )
+ assertEquals(CashCardTap.Collect(thanks = true), state.cashCardTap)
+ assertEquals(
+ CashCardTap.Collect(thanks = true),
+ ChatViewModel.State(subject = dm).cashCardTap,
+ )
+ }
+
+ @Test
+ fun `an eligible non-member is told to join rather than collecting`() {
+ // The one reader outside the group who can see a card to tap. Whatever the card does must
+ // not open the link, so the claim path is never reached from here.
+ val state = ChatViewModel.State(
+ subject = group(isMember = false),
+ groupAccess = GroupAccess.Eligible,
+ )
+ assertTrue(state.readsFromOutside)
+ assertEquals(CashCardTap.JoinToCollect, state.cashCardTap)
+ }
+
+ @Test
+ fun `every viewer outside the group is told to join`() {
+ // A blurred transcript has nothing to tap, but the answer must not depend on that.
+ for (state in listOf(
+ ChatViewModel.State(subject = group(isMember = false), groupAccess = GroupAccess.Blocked(unmet)),
+ ChatViewModel.State(subject = group(isMember = false), groupAccess = null),
+ ChatViewModel.State(subject = group(isMember = null), groupAccess = GroupAccess.Eligible),
+ )) {
+ assertEquals(CashCardTap.JoinToCollect, state.cashCardTap)
+ }
+ }
+
+ @Test
+ fun `a viewer with no composer collects without a thank-you`() {
+ // The thank-you is a message the viewer posts, so a viewer who cannot post is never
+ // recorded for one, and the claim never reaches a send the server would refuse.
+ val deactivatedDm = ChatViewModel.State(subject = dm, isAnonymous = true)
+ assertEquals(CashCardTap.Collect(thanks = false), deactivatedDm.cashCardTap)
+
+ // The join's checkmark still holds the composer back for its beat.
+ val justJoined = ChatViewModel.State(
+ subject = group(isMember = true),
+ groupAccess = GroupAccess.Membered,
+ joinProgress = LoadingSuccessState(success = true),
+ )
+ assertEquals(CashCardTap.Collect(thanks = false), justJoined.cashCardTap)
+ }
+
/** What the ViewModel's own `draftSnapshot` composes, over a state the reducer produced. */
private fun ChatViewModel.State.draftSnapshot(): ChatDraftSnapshot = chatDraftOf(
composerText = chatInputState.text.toString(),
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt
index 27f68e5018..6cf70d9cb3 100644
--- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt
@@ -69,14 +69,15 @@ sealed interface ChatAction {
data object CancelReply : ChatAction
/**
- * The reader tapped a cash voucher, naming the link they are about to open.
+ * The reader tapped a cash voucher.
*
- * Not a request to claim it, and not what opens it: the tap still leaves through the URL
+ * Not a request to claim it. The handler decides whether [url] opens at all — a non-member
+ * reading a group is told to join first — and when it does, the link leaves through the URL
* handler and comes back as a deeplink, which is what keeps the card path off the claim path.
- * This is the chat saying which link left from here, because that is the one thing lost at
+ * [entropy] is the chat noting which link left from here, because that is the one thing lost at
* that boundary — a claim arrives back knowing an entropy and nothing about a transcript.
*/
- data class CashLinkOpened(val entropy: String) : ChatAction
+ data class CashLinkOpened(val entropy: String, val url: String) : ChatAction
}
typealias ChatActionHandler = (ChatAction) -> Unit
diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt
index a0ab92f788..7f3e21153d 100644
--- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt
@@ -39,7 +39,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -572,8 +571,9 @@ private fun BareLinkCard(
/**
* What a tap on a card does.
*
- * A cash link goes back out through the URL handler its link span used, so replacing the text with
- * a card changed how the message looks and not what tapping it does.
+ * A cash link is handed to the chat, which opens it through the URL handler its link span used
+ * unless the reader cannot collect from where they are — a non-member reading a group. So the card
+ * changed how the message looks and not what tapping it does, except for that reader.
*
* A token link does not. Its URL classifies as a deep link, and the router answers that with the
* 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(
@Composable
private fun rememberLinkCardClick(): (LinkCard) -> Unit {
val actionHandler = LocalChatActionHandler.current
- val uriHandler = LocalUriHandler.current
return { card ->
when (card) {
- // The entropy is reported and the link still leaves through the URL handler, in that
- // order and unconditionally. Nothing here waits on the report or reads it back, so a
- // tap opens the link whatever the transcript does with the name.
- is LinkCard.Cash -> {
- actionHandler(ChatAction.CashLinkOpened(card.entropy))
- uriHandler.openUri(card.url)
- }
+ // 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))
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.