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.