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
3 changes: 3 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@
<string name="message_invite_contact">You should download Flipcash, a new way to send cash\n\n%1$s</string>
<!-- Posted by the claimer's own device as a reply to the voucher they just collected in a chat. -->
<string name="message_cash_link_thanks">Thanks for the cash!</string>
<!-- Shown when a non-member reading a group taps a cash link in it. The link is not opened. -->
<string name="title_joinToCollect">Join to Collect</string>
<string name="description_joinToCollect">Join this chat to collect cash sent in it.</string>

<string name="title_enterAddress">Enter Solana address</string>
<!-- v2 app-bar title for the withdrawal destination step; title_enterAddress stays the field placeholder. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -1009,9 +1038,23 @@ internal class ChatViewModel @Inject constructor(
*/
private fun initClaimReplies() {
eventFlow.filterIsInstance<Event.CashLinkOpened>()
.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<Event.CashLinkRefused>()
.onEach {
BottomBarManager.showInfo(
title = resources.getString(R.string.title_joinToCollect),
message = resources.getString(R.string.description_joinToCollect),
)
}
.launchIn(viewModelScope)
}

/**
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading