diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 5704c87c20..db92384ad5 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -412,11 +412,17 @@ sealed interface AppRoute : NavKey, Parcelable { // inside the app. The conversation offers a chip back to it; a link from outside the // app has nowhere to go back to and sets none. val returnToTitle: String? = null, + // Open with this request's details (its id in base58) over the transcript: where a + // payment of a share leads back to its request. Needs a [ChatIdentifier.ByChatId], since + // the details are keyed by chat. + val openRequestId: String? = null, ) : Messaging, FlowRoute { override val initialStack: List get() = when { openOnJoinRequests -> listOf(ChatStep.JoinRequests) openOnProfile -> listOf(ChatStep.GroupProfile) + openRequestId != null && identifier is ChatIdentifier.ByChatId -> + listOf(ChatStep.Conversation, ChatStep.RequestDetails(identifier.chatId, openRequestId)) else -> listOf(ChatStep.Conversation) } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt index 8adcf7cbec..5a7bc0e832 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.feed import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat @@ -112,6 +113,8 @@ sealed interface MessageMetadata { val userId: ID? = null, // Defaulted so a row persisted before this field existed still decodes (as null). val boostedReaction: BoostedReaction? = null, + // Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set. + val requestedPayment: RequestedPayment? = null, ) : MessageMetadata @Serializable @@ -126,6 +129,8 @@ sealed interface MessageMetadata { val userId: ID? = null, // Defaulted so a row persisted before this field existed still decodes (as null). val boostedReaction: BoostedReaction? = null, + // Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set. + val requestedPayment: RequestedPayment? = null, ) : MessageMetadata /** diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index be9f6e2903..35bd643e1f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1149,6 +1149,7 @@ Requests aren\'t available in this currency yet. Request + This request is no longer available. Requested by %1$s Requested by you ✓ Paid 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 db91f4cd26..a3b9914bbc 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 @@ -133,6 +133,7 @@ import com.flipcash.shared.chat.canReact import com.flipcash.shared.chat.chatDraftOf import com.flipcash.shared.chat.groupAccess import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.paysRequestShare import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.PhotoMessageContext @@ -1541,6 +1542,10 @@ internal class ChatViewModel @AssistedInject constructor( ) } + // A request share's payment is shown by the request card, not by a bubble. The + // message is still in the page: its key, and so the next page's, are unchanged. + if (content.paysRequestShare) return@flatMapIndexed emptyList() + val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) { val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token if (token != null) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt index f9f9a04329..ba0fea305f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt @@ -41,9 +41,7 @@ internal fun rememberPaymentRequestCards( requests = held, missing = missing, now = now, - openedAt = viewModel.openedAt, onNeeded = viewModel::fetchIfAbsent, - onRefresh = viewModel::refresh, onOpen = { open(chatId, it) }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt index e9cab01c02..f3764039e5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt @@ -6,16 +6,21 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.paymentrequest.PaymentRequest import com.flipcash.services.models.paymentrequest.deckOrder import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.PaymentRequests import com.getcode.opencode.model.core.ID import dagger.hilt.android.lifecycle.HiltViewModel +import com.flipcash.shared.chat.ui.request.RequestMotion +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -31,6 +36,7 @@ import kotlin.time.Instant @HiltViewModel internal class ChatRequestsViewModel @Inject constructor( private val requests: PaymentRequests, + private val chatCoordinator: ChatCoordinator, private val userManager: UserManager, ) : ViewModel() { @@ -60,17 +66,20 @@ internal class ChatRequestsViewModel @Inject constructor( /** The deck, front card first. Empty hides it. */ val deck: StateFlow> = _deck.asStateFlow() - /** Requests refetched because a message about them arrived, so a re-shown row doesn't refetch. */ - private val refreshed = mutableSetOf() - init { viewModelScope.launch { combine( chatId.flatMapLatest { id -> id?.let { requests.inChat(it) } ?: flowOf(emptyList()) }, _now, cycled, - ) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) } - .collect { _deck.value = it } + ) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) to now } + .collect { (next, now) -> publish(next, now) } + } + viewModelScope.launch { + chatId.filterNotNull().flatMapLatest { id -> + val arrivals = PaymentArrivals(openedAt) + chatCoordinator.observeMessages(id).map(arrivals::newlyPaid) + }.collect { paid -> paid.forEach(::refetch) } } viewModelScope.launch { while (true) { @@ -80,6 +89,35 @@ internal class ChatRequestsViewModel @Inject constructor( } } + private var shownNow = _now.value + private var settle: Job? = null + private var settledDeck: List = emptyList() + + /** + * Puts [next] on the deck. A request ending moves it in the sort order; the deck keeps its + * order for the ended look's fade first, and only then lets the list slide it into place. + * Changes arriving meanwhile update the cards in place and join the pending settle. + */ + private fun publish(next: List, now: Instant) { + val shown = _deck.value + val holding = settle?.isActive == true + val held = if (holding) inShownOrder(shown, next) else holdForEndedFade(shown, shownNow, next, now) + shownNow = now + settledDeck = next + if (held == null) { + settle?.cancel() + _deck.value = next + return + } + _deck.value = held + if (!holding) { + settle = viewModelScope.launch { + delay(RequestMotion.CHANGE_MILLIS.toLong()) + _deck.value = settledDeck + } + } + } + /** * Points the deck at [chat] and fills it from the requests already held, so a chat opened with * known requests shows its deck on the first frame instead of fading it in. @@ -108,9 +146,9 @@ internal class ChatRequestsViewModel @Inject constructor( viewModelScope.launch { requests.fetchIfAbsent(id) } } - fun refresh(id: ID) { - if (!refreshed.add(id)) return - viewModelScope.launch { requests.fetch(id) } + /** A failed refetch leaves the held copy as it was; the 45 s poll reconciles it. */ + private fun refetch(id: ID) { + viewModelScope.launch { runCatching { requests.refetch(id) } } } /** Swipe down on the deck: the front card goes to the back. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHold.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHold.kt new file mode 100644 index 0000000000..03360cf90a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHold.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.paymentrequest.PaymentRequest +import kotlin.time.Instant + +/** + * [next]'s contents in [shown]'s order, when ending a request is all that moves the deck: the same + * requests, a request that was open at [shownNow] and is ended at [now], and a different order. + * The deck keeps [shown]'s order while the ended look fades, then settles to [next]. Null when + * nothing needs holding. + */ +internal fun holdForEndedFade( + shown: List, + shownNow: Instant, + next: List, + now: Instant, +): List? { + if (shown.map { it.id } == next.map { it.id }) return null + val slotted = inShownOrder(shown, next) ?: return null + val ended = shown.indices.any { shown[it].isOpen(shownNow) && !slotted[it].isOpen(now) } + return slotted.takeIf { ended } +} + +/** [next]'s contents in [shown]'s order, or null unless both hold the same requests. */ +internal fun inShownOrder(shown: List, next: List): List? { + if (shown.size != next.size) return null + val byId = next.associateBy { it.id } + if (byId.size != next.size || shown.any { it.id !in byId }) return null + return shown.map { byId.getValue(it.id) } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt index 0c8e3f3462..fb9c402341 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt @@ -134,7 +134,9 @@ internal class DeckHostState(owed: Set) { } } -private val SETTLE_SPRING = spring(stiffness = Spring.StiffnessMediumLow) +/** The deck's settle motion; a reorder after a request ends slides on it too. */ +internal const val SETTLE_STIFFNESS = Spring.StiffnessMediumLow +private val SETTLE_SPRING = spring(stiffness = SETTLE_STIFFNESS) /** A [DeckHostState] for one visit to a chat; reopening the chat starts it over. */ @Composable diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivals.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivals.kt new file mode 100644 index 0000000000..022226ddc5 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivals.kt @@ -0,0 +1,24 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import kotlin.time.Instant + +/** + * Picks out the requests an open chat's cash messages pay. A payment landing changes its request + * and nothing else says so, so each such message sent after [openedAt] earns one refetch. Older + * messages are history loading in, and a message seen again (the list re-emits on every change) + * is not counted twice. Two messages for one request are two refetches; the in-flight dedupe in + * `PaymentRequests.refetch` folds those that overlap. + */ +internal class PaymentArrivals(private val openedAt: Instant) { + private val seen = mutableSetOf() + + /** The request ids to refetch for [messages], one per message not seen before. */ + fun newlyPaid(messages: List): List = + messages.mapNotNull { message -> + val id = message.content.firstNotNullOfOrNull { (it as? MessageContent.Cash)?.paymentRequestId } + id?.takeIf { message.timestamp > openedAt && seen.add(message.messageId) } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt index 2d8b3144b2..180fd31615 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt @@ -2,6 +2,10 @@ package com.flipcash.app.messenger.internal.request import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import com.flipcash.shared.chat.ui.request.RequestMotion import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout @@ -546,11 +550,12 @@ private fun StripFront( val share = request.unpaidShareOf(viewerId, now) val standing = request.viewerStanding(viewerId, now) val ended = !request.isOpen(now) + val alpha by animateFloatAsState(if (ended) ENDED_ALPHA else 1f, RequestMotion.change(), label = "ended fade") Row( modifier = modifier .height(DeckStyle.Strip.height) .background(CodeTheme.colors.background, shape) - .alpha(if (ended) ENDED_ALPHA else 1f) + .alpha(alpha) .background(CodeTheme.colors.surfaceVariant, shape) .border(1.dp, if (share != null) CodeTheme.colors.success else CodeTheme.colors.border, shape) .padding(horizontal = CodeTheme.dimens.grid.x3), @@ -649,10 +654,11 @@ private fun DeckFront( val ended = !request.isOpen(now) val split = request.split val standing = request.viewerStanding(viewerId, now) + val alpha by animateFloatAsState(if (ended) ENDED_ALPHA else 1f, RequestMotion.change(), label = "ended fade") Row( modifier = modifier .background(CodeTheme.colors.background, PILL_SHAPE) - .alpha(if (ended) ENDED_ALPHA else 1f) + .alpha(alpha) .background(CodeTheme.colors.surfaceVariant, PILL_SHAPE) .border(1.dp, CodeTheme.colors.border, PILL_SHAPE) .padding( @@ -752,7 +758,12 @@ internal fun RequestRing( val status = request.resolvedStatus(now) val ended = status != PaymentRequestStatus.Open val fraction = if (status == PaymentRequestStatus.Completed) 1f else request.split?.paidFraction ?: 0f - val accent = if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success + val accent by animateColorAsState( + if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, + RequestMotion.change(), + label = "ring accent", + ) + val sweep by animateFloatAsState(fraction, RequestMotion.change(), label = "ring sweep") val track = CodeTheme.colors.divider val inset = size * (4f / 26f) Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) { @@ -761,17 +772,19 @@ internal fun RequestRing( val arcSize = Size(this.size.width - stroke, this.size.height - stroke) val topLeft = Offset(stroke / 2, stroke / 2) drawArc(track, 0f, 360f, false, topLeft, arcSize, style = Stroke(stroke)) - if (fraction > 0f) { - drawArc(accent, -90f, 360f * fraction.coerceIn(0f, 1f), false, topLeft, arcSize, style = Stroke(stroke)) + if (sweep > 0f) { + drawArc(accent, -90f, 360f * sweep.coerceIn(0f, 1f), false, topLeft, arcSize, style = Stroke(stroke)) } drawCircle(accent, radius = this.size.minDimension / 2 - inset.toPx()) } - Text( - text = if (status == PaymentRequestStatus.Closed) "✓" else "$", - style = if (size > 30.dp) CodeTheme.typography.textMedium else CodeTheme.typography.caption, - color = Color.Black, - ) - if (paid) PaidBadge(glyph = size, ended = ended) + Crossfade(targetState = status == PaymentRequestStatus.Closed, animationSpec = RequestMotion.change(), label = "ring glyph") { closed -> + Text( + text = if (closed) "✓" else "$", + style = if (size > 30.dp) CodeTheme.typography.textMedium else CodeTheme.typography.caption, + color = Color.Black, + ) + } + PaidBadge(glyph = size, ended = ended, paid = paid) } } @@ -865,6 +878,11 @@ internal fun ExpandedRequests( ) { itemsIndexed(deck, key = { _, request -> request.id.hashCode() }) { index, request -> val title = request.deckTitle + val endedAlpha by animateFloatAsState( + if (request.isOpen(now)) 1f else ENDED_ALPHA, + RequestMotion.change(), + label = "ended fade", + ) val card = Modifier .fillMaxWidth() .then( @@ -880,12 +898,15 @@ internal fun ExpandedRequests( enter = slideInVertically { -it * (index + 1) } + fadeIn(), exit = slideOutVertically { -it * (index + 1) } + fadeOut(), ) + // A reorder after a request ends slides into place on the deck's settle spring. + .animateItem(fadeInSpec = null, fadeOutSpec = null, placementSpec = SETTLE_SPRING_OFFSET) + } ) // surfaceVariant is translucent; the opaque base keeps the transcript from // showing through, and sits outside the alpha so ended cards stay solid. .background(CodeTheme.colors.background, CARD_SHAPE) - .alpha(if (request.isOpen(now)) 1f else ENDED_ALPHA) + .alpha(endedAlpha) .background(CodeTheme.colors.surfaceVariant, CARD_SHAPE) .clickable { onOpen(request) } .padding(CodeTheme.dimens.grid.x3) @@ -979,6 +1000,7 @@ private const val FLING_VELOCITY = 1_000f private const val DROP_SCALE = 0.06f // How far past one pill height the front card can be pulled, as a fraction of it. private const val RUBBER_BAND = 0.25f +private val SETTLE_SPRING_OFFSET = spring(stiffness = SETTLE_STIFFNESS) private val DECK_SPRING = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt index a3f2716781..82e54df812 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsScreen.kt @@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Box +import com.flipcash.shared.chat.ui.request.RequestMotion import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -112,6 +114,7 @@ internal fun RequestDetailsScreen( val token by viewModel.token.collectAsStateWithLifecycle() val pay by viewModel.pay.collectAsStateWithLifecycle() val now by viewModel.now.collectAsStateWithLifecycle() + val unavailable by viewModel.unavailable.collectAsStateWithLifecycle() val viewerId = viewModel.viewerId val current = request @@ -170,7 +173,23 @@ internal fun RequestDetailsScreen( } }, ) { padding -> - if (current == null) return@CodeScaffold + if (current == null) { + if (unavailable) { + Box( + modifier = Modifier.fillMaxSize().padding(padding).padding(CodeTheme.dimens.inset), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.description_requestUnavailable), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.testTag("request_details_unavailable"), + ) + } + } + return@CodeScaffold + } RequestDetailsContent( request = current, members = members, @@ -344,15 +363,22 @@ private fun ShareRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = when { - share.isPaid -> stringResource(R.string.label_sharePaid) - requestOpen -> stringResource(R.string.label_shareUnpaid) - else -> stringResource(R.string.label_shareUnpaidEnded) - }, - style = CodeTheme.typography.textSmall, - color = if (share.isPaid) CodeTheme.colors.success else CodeTheme.colors.textSecondary, - ) + // Fades to the paid state when the share turns paid while the screen is up. + Crossfade( + targetState = if (share.isPaid) 2 else if (requestOpen) 1 else 0, + animationSpec = RequestMotion.change(), + label = "share state", + ) { state -> + Text( + text = when (state) { + 2 -> stringResource(R.string.label_sharePaid) + 1 -> stringResource(R.string.label_shareUnpaid) + else -> stringResource(R.string.label_shareUnpaidEnded) + }, + style = CodeTheme.typography.textSmall, + color = if (state == 2) CodeTheme.colors.success else CodeTheme.colors.textSecondary, + ) + } } Text( text = share.amount.requestText(), diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt index ba50c9372f..92af2607d9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDetailsViewModel.kt @@ -117,6 +117,14 @@ internal class RequestDetailsViewModel @Inject constructor( .flatMapLatest { s -> s.requestId?.let(requests::request) ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** + * Whether a fetch found the request DENIED or NOT_FOUND (the viewer left the group, say) and + * none is held to show. The screen says so instead of staying blank. + */ + val unavailable: StateFlow = combine(_state, requests.missing, request) { s, missing, held -> + held == null && s.requestId in missing + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + /** The chat's members by id, for the creator's and each share's name and avatar. */ val members: StateFlow> = combine(_state, rosters.state) { s, all -> all[s.chatId].orEmpty().associateBy { it.userId } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index b1aa33739a..58c7dea4f3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -330,9 +330,9 @@ internal fun MessageList( // over an empty list has no append to hint. A quote tap is past this already. snapshotFlow { hasLoaded && listState.layoutInfo.visibleItemsInfo.isNotEmpty() } .first { it } - messages.walkUntil(budget) { indexOf(messages, target) }?.let { + messages.walkUntil(budget) { indexOfOrNearestOlder(messages, target) }?.let { listState.centerItem(it) - attentionId = target + attentionId = (messages.peek(it) as? ChatListItem.ContentBubble)?.messageId ?: target attentionRequest++ } // A visit opened at a message is positioned once this lands, found or not: reads are @@ -1070,6 +1070,21 @@ private fun indexOf(messages: LazyPagingItems, messageId: Long): I (messages.peek(i) as? ChatListItem.ContentBubble)?.messageId == messageId } +/** + * [indexOf], or the newest loaded message older than [messageId] when it isn't drawn. A request + * share's cash message is stored and paged but has no row, and a link or quote to it (a payment + * push opens at its message) lands on its neighbour instead of walking the whole budget for nothing. + * + * Rows run newest first, so the first one at or below [messageId] is the nearest. Pending sends + * carry non-positive ids and say nothing about where [messageId] sits. A target that is simply not + * loaded yet has no such row, because every loaded message is newer than it. + */ +private fun indexOfOrNearestOlder(messages: LazyPagingItems, messageId: Long): Int? = + indexOf(messages, messageId) ?: (0 until messages.itemCount).firstOrNull { i -> + val id = (messages.peek(i) as? ChatListItem.ContentBubble)?.messageId + id != null && id > 0 && id <= messageId + } + /** * Brings [index] to the middle of the transcript, which is where iOS lands a jump too * (`ChatViewController.scrollToRow`, `.centeredVertically`). diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckHoldTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckHoldTest.kt new file mode 100644 index 0000000000..760a81a3e1 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckHoldTest.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.Share +import com.flipcash.services.models.paymentrequest.Split +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.time.Instant + +/** The deck holds its order while an ending request fades, then settles. */ +class DeckHoldTest { + + private val now = Instant.fromEpochSeconds(500) + + private fun request(id: Int, status: PaymentRequestStatus = PaymentRequestStatus.Open, endsAt: Long = 1_000) = PaymentRequest( + id = List(16) { id.toByte() }, + creator = List(16) { 1 }, + createdAt = Instant.fromEpochSeconds(0), + status = status, + closedAt = null, + version = 1, + kind = PaymentRequest.Kind.GroupSplit( + chatId = ChatId(ByteArray(32) { 3 }), + split = Split( + text = PaymentRequestText.Plain("Dinner", ""), + currency = CurrencyCode.USD, + endsAt = Instant.fromEpochSeconds(endsAt), + shares = listOf(Share(List(16) { 2 }, Fiat(20.0, CurrencyCode.USD), null)), + ), + ), + ) + + @Test + fun `a request closing keeps the old order with the new contents`() { + val a = request(1) + val b = request(2) + val closedA = request(1, PaymentRequestStatus.Closed) + val held = holdForEndedFade(shown = listOf(a, b), shownNow = now, next = listOf(b, closedA), now = now) + assertEquals(listOf(closedA, b), held) + } + + @Test + fun `a request expiring by the clock holds the order too`() { + val a = request(1, endsAt = 600) + val b = request(2) + val later = Instant.fromEpochSeconds(700) + val held = holdForEndedFade(listOf(a, b), now, listOf(b, a), later) + assertEquals(listOf(a, b), held) + } + + @Test + fun `an order change with nothing ending is not held`() { + val a = request(1) + val b = request(2) + assertNull(holdForEndedFade(listOf(a, b), now, listOf(b, a), now)) + } + + @Test + fun `an unchanged order needs no hold`() { + val a = request(1) + val closedA = request(1, PaymentRequestStatus.Closed) + assertNull(holdForEndedFade(listOf(a), now, listOf(closedA), now)) + } + + @Test + fun `a different set of requests is not held`() { + val a = request(1) + val b = request(2) + val c = request(3) + assertNull(holdForEndedFade(listOf(a, b), now, listOf(c, request(1, PaymentRequestStatus.Closed)), now)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivalsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivalsTest.kt new file mode 100644 index 0000000000..c7ff1c3c0f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/PaymentArrivalsTest.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Instant + +/** A cash message that pays a request earns that request one refetch, once per message. */ +class PaymentArrivalsTest { + + private val openedAt = Instant.fromEpochSeconds(1_000) + private val after = Instant.fromEpochSeconds(1_001) + private val before = Instant.fromEpochSeconds(999) + private val request: ID = List(16) { 7 } + + private fun cash(id: Long, at: Instant, pays: ID? = request, intent: Byte = id.toByte()) = ChatMessage( + messageId = id, + senderId = null, + content = listOf( + MessageContent.Cash( + intentId = List(32) { intent }, + amount = Fiat(5.0), + mint = Mint.usdf, + paymentRequestId = pays, + ), + ), + timestamp = at, + unreadSeq = id, + ) + + @Test + fun `a new cash message with a request id triggers a refetch`() { + val arrivals = PaymentArrivals(openedAt) + assertEquals(listOf(request), arrivals.newlyPaid(listOf(cash(1, after)))) + } + + @Test + fun `a cash message without a request id does not`() { + val arrivals = PaymentArrivals(openedAt) + assertEquals(emptyList(), arrivals.newlyPaid(listOf(cash(1, after, pays = null)))) + } + + @Test + fun `a cash message older than the chat open does not`() { + val arrivals = PaymentArrivals(openedAt) + assertEquals(emptyList(), arrivals.newlyPaid(listOf(cash(1, before)))) + } + + @Test + fun `the same message seen twice triggers one refetch`() { + val arrivals = PaymentArrivals(openedAt) + val message = cash(1, after) + assertEquals(listOf(request), arrivals.newlyPaid(listOf(message))) + assertEquals(emptyList(), arrivals.newlyPaid(listOf(message))) + } + + @Test + fun `two messages for the same request trigger two refetches`() { + val arrivals = PaymentArrivals(openedAt) + assertEquals(listOf(request), arrivals.newlyPaid(listOf(cash(1, after)))) + assertEquals(listOf(request), arrivals.newlyPaid(listOf(cash(1, after), cash(2, after)))) + } +} diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt index 32d07f14a9..45c0423cf1 100644 --- a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/TransactionDetailsScreen.kt @@ -56,6 +56,9 @@ fun TransactionDetailsScreen(id: ID) { onViewMessage = { state.boostedMessageRoute?.let { navigator.push(it) } }, + onViewRequest = { + state.requestedPaymentRoute?.let { navigator.push(it) } + }, onCancel = { viewModel.dispatchEvent(TransactionDetailsViewModel.Event.OnCancelRequested) }, ) } diff --git a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt index fa64d22c35..4e5758d066 100644 --- a/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt +++ b/apps/flipcash/features/transactions/src/main/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModel.kt @@ -22,6 +22,7 @@ import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.core.ID +import com.getcode.utils.base58 import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -92,6 +93,22 @@ class TransactionDetailsViewModel @Inject constructor( jumpToMessageId = boost.messageId, ) } + + /** + * Where "View Request" goes when the entry paid a share of a request: the chat with that + * request's details open over it. Read off the stored snapshot and not validated, so a + * request that has since closed, or that the viewer can no longer read, is handled by + * Request details itself. A boost wins if both are set. + */ + val requestedPaymentRoute: AppRoute? get() { + val details = transaction?.details ?: return null + if (details.boostedReaction != null) return null + val requested = details.requestedPayment ?: return null + return AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(requested.chatId), + openRequestId = requested.paymentRequestId.base58, + ) + } } sealed interface Event { diff --git a/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt b/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt index fe13d0d0a7..1e47b64639 100644 --- a/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt +++ b/apps/flipcash/features/transactions/src/test/kotlin/com/flipcash/app/transactions/internal/TransactionDetailsViewModelTest.kt @@ -9,6 +9,8 @@ import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment +import com.getcode.utils.base58 import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji import com.flipcash.shared.transactionhistory.TransactionDetails @@ -164,4 +166,41 @@ class TransactionDetailsViewModelTest { assertNull(state.boostedMessageRoute) assertTrue(state.transaction!!.details.canViewInChat) } + + // --- "View Request": the route opens the request's details over its chat. --- + + private val requested = RequestedPayment(chatId = dmChatId, paymentRequestId = List(16) { it.toByte() }) + + private fun stateWith(requested: RequestedPayment?, boost: BoostedReaction?): TransactionDetailsViewModel.State { + val details = mockk(relaxed = true) { + every { requestedPayment } returns requested + every { boostedReaction } returns boost + } + val transaction = mockk(relaxed = true) { every { this@mockk.details } returns details } + return TransactionDetailsViewModel.State(transaction = transaction) + } + + @Test + fun `R1 a requested payment opens its request in its chat`() { + val route = stateWith(requested, boost = null).requestedPaymentRoute + assertEquals( + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(dmChatId), + openRequestId = requested.paymentRequestId.base58, + ), + route, + ) + } + + @Test + fun `R2 a boost wins when both are set`() { + val state = stateWith(requested, boost) + assertNull(state.requestedPaymentRoute) + assertEquals(boostRoute, state.boostedMessageRoute) + } + + @Test + fun `R3 a payment with no request has no request route`() { + assertNull(stateWith(null, null).requestedPaymentRoute) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/RequestShare.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/RequestShare.kt new file mode 100644 index 0000000000..0ab8c7c375 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/RequestShare.kt @@ -0,0 +1,14 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MessageContent + +/** + * Whether this content is a cash message that pays a payment request share. The request's card and + * Request details already show that payment, so the transcript draws no bubble for it. The message + * stays stored and paged; only the mapping to rows skips it. + * + * The chat list's preview and unread queries apply the same test in SQL (`ChatMessageDao`), so a + * change here has to change them too. + */ +val MessageContent.paysRequestShare: Boolean + get() = this is MessageContent.Cash && paymentRequestId != null 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 c1063d915b..1d98ed7504 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 @@ -74,7 +74,6 @@ import com.flipcash.shared.chat.ui.request.FetchWhenAbsent import com.flipcash.shared.chat.ui.request.LocalPaymentRequestCards import com.flipcash.shared.chat.ui.request.PaymentRequestBubbleDefaults import com.flipcash.shared.chat.ui.request.PaymentRequestCardBody -import com.flipcash.shared.chat.ui.request.RefreshOnArrival import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.LinkCard @@ -217,9 +216,6 @@ fun ContentBubble( return@Row } - // A share payment landing changes its request, and nothing else says so. - (item.content as? MessageContent.Cash)?.paymentRequestId?.let { RefreshOnArrival(it, item.timestamp) } - when (val content = item.content) { is MessageContent.Text -> TextBubble( modifier = modifier, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt index 73f67ec4e4..8fae3e3b7f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestBubble.kt @@ -25,12 +25,8 @@ class PaymentRequestCards( val requests: Map, val missing: Set, val now: Instant, - /** When the chat screen opened: messages after it arrived while the reader watched. */ - val openedAt: Instant, /** Fetches a request the store doesn't hold; safe to call on every composition. */ val onNeeded: (ID) -> Unit, - /** Refetches a request whether or not the store holds it. */ - val onRefresh: (ID) -> Unit, val onOpen: (ID) -> Unit, ) { fun stateOf(id: ID, isFromSelf: Boolean, senderId: ID?): RequestCardState = requestCardState( @@ -55,14 +51,6 @@ internal fun FetchWhenAbsent(id: ID, cards: PaymentRequestCards) { LaunchedEffect(id, absent) { if (absent) cards.onNeeded(id) } } -/** Refetches [id] once when a message about it, sent at [sentAt], arrives while the chat is open. */ -@Composable -internal fun RefreshOnArrival(id: ID, sentAt: Instant) { - val cards = LocalPaymentRequestCards.current ?: return - val arrived = sentAt > cards.openedAt - LaunchedEffect(id, arrived) { if (arrived) cards.onRefresh(id) } -} - @Composable internal fun PaymentRequestCardBody(state: RequestCardState, now: Instant) { when (state) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt index 5462c69d26..4c54bec410 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/PaymentRequestUi.kt @@ -2,6 +2,9 @@ package com.flipcash.shared.chat.ui.request import androidx.compose.ui.platform.LocalContext import android.content.res.Resources +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -85,18 +88,24 @@ fun PaymentRequest.progressText(): String { @Composable fun RequestProgressBar(fraction: Float, ended: Boolean, modifier: Modifier = Modifier) { val shape = RoundedCornerShape(1.5.dp) + val shown by animateFloatAsState(fraction, RequestMotion.change(), label = "request progress") + val fill by animateColorAsState( + if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, + RequestMotion.change(), + label = "request progress fill", + ) Box( modifier = modifier .fillMaxWidth() .height(3.dp) .background(CodeTheme.colors.divider, shape), ) { - if (fraction > 0f) { + if (shown > 0f) { Box( modifier = Modifier .fillMaxHeight() - .fillMaxWidth(fraction.coerceIn(0f, 1f)) - .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, shape), + .fillMaxWidth(shown.coerceIn(0f, 1f)) + .background(fill, shape), ) } } @@ -124,6 +133,11 @@ fun PaymentRequestCardContent( ) { val loading = request == null || title == null val ended = request != null && !request.isOpen(now) + val glyphFill by animateColorAsState( + if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, + RequestMotion.change(), + label = "request glyph fill", + ) Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), @@ -135,11 +149,11 @@ fun PaymentRequestCardContent( Box( modifier = glyphModifier .size(34.dp) - .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, CircleShape), + .background(glyphFill, CircleShape), contentAlignment = Alignment.Center, ) { Text(text = "$", style = CodeTheme.typography.textLarge, color = Color.Black) - if (standing is ViewerStanding.Paid) PaidBadge(glyph = 34.dp, ended = ended) + PaidBadge(glyph = 34.dp, ended = ended, paid = standing is ViewerStanding.Paid) } Column { Text( @@ -179,16 +193,18 @@ fun PaymentRequestCardContent( val viewer = standing?.takeUnless { loading }?.text() if (viewer != null) { Spacer(Modifier.weight(1f)) - Text( - text = viewer, - style = CodeTheme.typography.caption, - color = when (standing) { - is ViewerStanding.Paid, is ViewerStanding.Owes -> CodeTheme.colors.success - else -> CodeTheme.colors.textSecondary - }, - maxLines = 1, - modifier = detailModifier, - ) + // The viewer's part cross-fades when it changes, as a share turns paid. + Crossfade(targetState = viewer, animationSpec = RequestMotion.change(), modifier = detailModifier, label = "viewer standing") { text -> + Text( + text = text, + style = CodeTheme.typography.caption, + color = when (standing) { + is ViewerStanding.Paid, is ViewerStanding.Owes -> CodeTheme.colors.success + else -> CodeTheme.colors.textSecondary + }, + maxLines = 1, + ) + } } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt index 003b5d4311..e103bdd530 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/request/ViewerStatus.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.draw.alpha import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -132,23 +134,45 @@ private object FirstThatFitsPolicy : MeasurePolicy { measurables.maxOf { it.minIntrinsicHeight(width) } } +/** + * How a live change to a request moves: the same ease-out over [CHANGE_MILLIS] for the ring and + * bar sweeping to a new value, the ended look fading in, and a share turning paid. Tweens follow + * the system animator duration scale, so they are instant with animations off. Every use starts + * at its current value, so nothing plays on first appearance. + */ +object RequestMotion { + const val CHANGE_MILLIS = 300 + + fun change(): androidx.compose.animation.core.TweenSpec = + androidx.compose.animation.core.tween(CHANGE_MILLIS, easing = androidx.compose.animation.core.LinearOutSlowInEasing) +} + /** * The paid badge at a glyph's bottom-right: a success circle with a black "✓", ringed in the card * surface so it reads apart from the glyph. 10dp on a 26dp glyph, scaled with [glyph]. Greys with * the glyph once the request has ended. */ @Composable -fun BoxScope.PaidBadge(glyph: Dp, ended: Boolean) { +fun BoxScope.PaidBadge(glyph: Dp, ended: Boolean, paid: Boolean = true) { + // Fades in when the share turns paid; already paid when first drawn, it just shows. + val visibility by androidx.compose.animation.core.animateFloatAsState(if (paid) 1f else 0f, RequestMotion.change(), label = "paid badge") + val fill by androidx.compose.animation.animateColorAsState( + if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, + RequestMotion.change(), + label = "paid badge fill", + ) + if (visibility <= 0f) return val badge = glyph * (10f / 26f) val ring = glyph * (1.5f / 26f) val surface = CodeTheme.colors.surfaceVariant.compositeOver(CodeTheme.colors.background) Box( modifier = Modifier .align(Alignment.BottomEnd) + .alpha(visibility) .size(badge + ring * 2) .background(surface, CircleShape) .padding(ring) - .background(if (ended) CodeTheme.colors.textSecondary else CodeTheme.colors.success, CircleShape), + .background(fill, CircleShape), contentAlignment = Alignment.Center, ) { Text( diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/RequestShareTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/RequestShareTest.kt new file mode 100644 index 0000000000..0e5fcd6ccf --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/RequestShareTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.financial.toFiat +import com.getcode.solana.keys.Mint +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RequestShareTest { + + @Test + fun `a cash message carrying a request id pays a share`() { + assertTrue(cash(paymentRequestId = byteArrayOf(1, 2, 3).toList()).paysRequestShare) + } + + @Test + fun `a cash message without a request id is a normal payment`() { + assertFalse(cash(paymentRequestId = null).paysRequestShare) + } + + @Test + fun `other content never pays a share`() { + assertFalse(MessageContent.Text("hi").paysRequestShare) + } + + private fun cash(paymentRequestId: List?) = + MessageContent.Cash( + intentId = listOf(1), + amount = 5.toFiat(), + mint = Mint.usdf, + paymentRequestId = paymentRequestId, + ) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestUpdateSink.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestUpdateSink.kt new file mode 100644 index 0000000000..9980994758 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequestUpdateSink.kt @@ -0,0 +1,17 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate +import com.getcode.opencode.model.core.ID + +/** Where the chat event stream sends payment request updates. [PaymentRequests] is the real one. */ +interface PaymentRequestUpdateSink { + /** Applies [updates] in order and returns the requests whose held copy needs a [refetch]. */ + suspend fun onUpdates(updates: List): List + + suspend fun refetch(id: ID) + + object None : PaymentRequestUpdateSink { + override suspend fun onUpdates(updates: List): List = emptyList() + override suspend fun refetch(id: ID) = Unit + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt index 8767aff282..274a99ad28 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PaymentRequests.kt @@ -12,7 +12,9 @@ import com.flipcash.services.models.paymentrequest.PaymentRequest import com.flipcash.services.models.paymentrequest.PaymentRequestDetails import com.flipcash.services.models.paymentrequest.PaymentRequestPage import com.flipcash.services.models.paymentrequest.PaymentRequestSection +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate import com.flipcash.services.models.paymentrequest.newerOf import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow @@ -28,15 +30,17 @@ import javax.inject.Singleton /** * The payment requests the viewer has seen, by id, each the newest copy fetched. * - * No event carries a request change, so screens refetch (see the spec's refetch table) and every - * fetched request goes through [ingest]: its text is opened once, then [merge] keeps the newer copy. + * Screens refetch (see the spec's refetch table) and every fetched request goes through [ingest]: + * its text is opened once, then [merge] keeps the newer copy. The chat's event stream also announces + * changes, best effort, which [onUpdates] applies by version (see [applyUpdate]); polling stays as + * reconciliation for whatever it misses. * A request's `payment_intent_id` never enters the store; [fetch] hands it to the caller. */ @Singleton class PaymentRequests @Inject constructor( private val controller: PaymentRequestController, private val crypto: GroupContentCrypto, -) { +) : PaymentRequestUpdateSink { private val requests = MutableStateFlow>(emptyMap()) val state: StateFlow> = requests.asStateFlow() @@ -114,6 +118,43 @@ class PaymentRequests @Inject constructor( requests.update { held -> opened.fold(held, ::merge) } } + /** + * Applies the stream's request updates in order. A `created` goes through [ingest]; an + * `updates` is applied or ignored by [applyUpdate], which also says when the held copy cannot + * be trusted. Those requests are returned for [refetch] rather than fetched here, so the + * stream is not held up by the network. + */ + override suspend fun onUpdates(updates: List): List { + val refetch = LinkedHashSet() + for (update in updates) { + when (val kind = update.kind) { + is PaymentRequestUpdate.Kind.Created -> ingest(listOf(kind.paymentRequest)) + is PaymentRequestUpdate.Kind.Updates, PaymentRequestUpdate.Kind.Unknown -> { + var stale = false + requests.update { held -> + val outcome = applyUpdate(held, update) + stale = outcome.refetch + outcome.requests + } + if (stale) refetch += update.paymentRequestId + } + } + } + return refetch.toList() + } + + /** Refetches [id] to reconcile a missed or unappliable update; one fetch at a time per request. */ + override suspend fun refetch(id: ID) { + var claimed = false + inFlight.update { held -> claimed = id !in held; held.plusElement(id) } + if (!claimed) return + try { + fetch(id) + } finally { + inFlight.update { it.minusElement(id) } + } + } + fun clear() { requests.value = emptyMap() _missing.value = emptySet() @@ -133,6 +174,9 @@ class PaymentRequests @Inject constructor( return request.copy(kind = kind.copy(split = kind.split.copy(text = text))) } + /** [requests] after an update, and whether the request must be refetched to reconcile it. */ + data class UpdateOutcome(val requests: Map, val refetch: Boolean) + companion object { const val OPEN_PAGE = 100 const val RESOLVED_PAGE = 20 @@ -150,5 +194,55 @@ class PaymentRequests @Inject constructor( if (sealedOverOpened) return held return held + (incoming.id to newerOf(current, incoming)) } + + /** + * Applies an [PaymentRequestUpdate.Kind.Updates] to the held copy of its request, by version + * (L is the held version, V the update's): + * - no held copy: ignored; the request is fetched when it is shown; + * - V <= L: already applied, ignored; + * - V > L + 1: an update was missed, so refetch; + * - V == L + 1: every change is applied in order and the version set to V, unless one + * cannot be (unknown kind, or a share the copy lacks), in which case none is applied and + * the request is refetched rather than taking V for a copy missing a change. + * + * A [PaymentRequestUpdate.Kind.Unknown] cannot be applied, so it refetches a request that is + * held. A `created` is not handled here: it merges like a fetched copy ([merge]). + */ + fun applyUpdate(held: Map, update: PaymentRequestUpdate): UpdateOutcome { + val current = held[update.paymentRequestId] ?: return UpdateOutcome(held, refetch = false) + val kind = update.kind + if (kind !is PaymentRequestUpdate.Kind.Updates) { + return UpdateOutcome(held, refetch = kind is PaymentRequestUpdate.Kind.Unknown) + } + if (kind.version <= current.version) return UpdateOutcome(held, refetch = false) + if (kind.version > current.version + 1) return UpdateOutcome(held, refetch = true) + val applied = kind.changes.fold(current) { copy, change -> + copy?.let { applyChange(it, change) } + } ?: return UpdateOutcome(held, refetch = true) + return UpdateOutcome(held + (current.id to applied.copy(version = kind.version)), refetch = false) + } + + /** [request] with [change] applied, or null when it can't be. */ + private fun applyChange(request: PaymentRequest, change: PaymentRequestUpdate.Change): PaymentRequest? = when (change) { + is PaymentRequestUpdate.Change.SharePaid -> { + val kind = request.kind as? PaymentRequest.Kind.GroupSplit + val share = kind?.split?.shareOf(change.userId) + if (kind == null || share == null || change.paid == null) null + else request.copy( + kind = kind.copy( + split = kind.split.copy( + // The update carries no amount: keep the held share's. + shares = kind.split.shares.map { if (it.userId == change.userId) it.copy(paid = change.paid) else it }, + ), + ), + ) + } + is PaymentRequestUpdate.Change.StatusChanged -> when (change.status) { + PaymentRequestStatus.Completed, PaymentRequestStatus.Closed -> + request.copy(status = change.status, closedAt = change.closedAt ?: request.closedAt) + else -> null + } + PaymentRequestUpdate.Change.Unknown -> null + } } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 4c0e821deb..0e6efa50e9 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -14,6 +14,8 @@ import com.flipcash.shared.chat.RosterSearchSource import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption import com.flipcash.shared.chat.alerts.ReactionAlertController +import com.flipcash.shared.chat.PaymentRequestUpdateSink +import com.flipcash.shared.chat.PaymentRequests import com.flipcash.shared.chat.alerts.ReactionAlertSink import com.flipcash.shared.chat.alerts.ReactionActorProfiles import com.flipcash.shared.chat.alerts.StoredReactionActorProfiles @@ -102,6 +104,11 @@ abstract class ChatModule { impl: StoredReactionActorProfiles ): ReactionActorProfiles + @Binds + internal abstract fun bindPaymentRequestUpdateSink( + impl: PaymentRequests + ): PaymentRequestUpdateSink + @Binds internal abstract fun bindReactionAlertSink( impl: ReactionAlertController diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 815d330c68..93da1bfc5b 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -30,6 +30,7 @@ import com.flipcash.shared.chat.alerts.ActorBoostTotals import com.flipcash.shared.chat.alerts.ReactionActorProfiles import com.flipcash.shared.chat.alerts.ReactionAlertEvent import com.flipcash.shared.chat.alerts.ReactionAlertPolicy +import com.flipcash.shared.chat.PaymentRequestUpdateSink import com.flipcash.shared.chat.alerts.ReactionAlertSink import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager @@ -101,6 +102,7 @@ class EventStreamDelegate @Inject constructor( private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None, private val reactionAlerts: ReactionAlertSink = ReactionAlertSink.None, private val actorProfiles: ReactionActorProfiles = ReactionActorProfiles.None, + private val paymentRequestUpdates: PaymentRequestUpdateSink = PaymentRequestUpdateSink.None, ) : EventStreamOperations { @@ -476,10 +478,15 @@ class EventStreamDelegate @Inject constructor( // --- Lobby changes --- - // TODO(flipcash2 0.23.0): ChatUpdate.paymentRequestUpdates arrive here but nothing applies - // them yet. Apply by version per PaymentRequestUpdate's docs (created: keep greater; - // updates: ignore <= held, apply in order at held + 1, refetch beyond or on an unknown - // change) once a payment request store consumes the stream. + // Best effort and unsequenced like the roster above: the store applies them by version, and + // refetches (off this path) any request whose update it missed or couldn't apply. + if (update.paymentRequestUpdates.isNotEmpty()) { + val stale = paymentRequestUpdates.onUpdates(update.paymentRequestUpdates) + if (stale.isNotEmpty()) { + scope?.launch { stale.forEach { paymentRequestUpdates.refetch(it) } } + } + } + if (update.lobbyUpdates.isNotEmpty()) { _events.send(Event.LobbyChanged(chatId, update.lobbyUpdates)) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsUpdateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsUpdateTest.kt new file mode 100644 index 0000000000..bcf2297374 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/PaymentRequestsUpdateTest.kt @@ -0,0 +1,189 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaidShare +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate +import com.flipcash.services.models.paymentrequest.PaymentRequestUpdate.Change +import com.flipcash.services.models.paymentrequest.Share +import com.flipcash.services.models.paymentrequest.Split +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** Request updates apply by version: ignore at or below, apply at +1, refetch beyond or when unappliable. */ +class PaymentRequestsUpdateTest { + + private val id = List(16) { 9 } + private val alice = List(16) { 2 } + private val bob = List(16) { 3 } + private val paidAt = Instant.fromEpochSeconds(500) + private val closedAt = Instant.fromEpochSeconds(600) + + private fun share(user: List, paid: PaidShare? = null) = + Share(user, Fiat(quarks = 10_000_000, currencyCode = CurrencyCode.USD), paid) + + private fun request(version: Long, shares: List = listOf(share(alice), share(bob))) = PaymentRequest( + id = id, + creator = List(16) { 1 }, + createdAt = Instant.fromEpochSeconds(0), + status = PaymentRequestStatus.Open, + closedAt = null, + version = version, + kind = PaymentRequest.Kind.GroupSplit( + chatId = ChatId(ByteArray(32) { 3 }), + split = Split(PaymentRequestText.Plain("Dinner", ""), CurrencyCode.USD, Instant.fromEpochSeconds(1_000), shares), + ), + ) + + private fun updates(version: Long, vararg changes: Change) = + PaymentRequestUpdate(id, PaymentRequestUpdate.Kind.Updates(version, changes.toList())) + + private val paid = PaidShare(paidAt, PaidShare.How.Payment) + + private fun apply(held: PaymentRequest?, update: PaymentRequestUpdate) = + PaymentRequests.applyUpdate(if (held == null) emptyMap() else mapOf(id to held), update) + + @Test + fun `an update at the next version applies and sets the version`() { + val out = apply(request(3), updates(4, Change.SharePaid(alice, paid))) + assertFalse(out.refetch) + val result = out.requests.getValue(id) + assertEquals(4, result.version) + assertEquals(paid, result.split?.shareOf(alice)?.paid) + assertNull(result.split?.shareOf(bob)?.paid) + } + + @Test + fun `a paid share keeps the held amount`() { + val held = request(3) + val result = apply(held, updates(4, Change.SharePaid(bob, paid))).requests.getValue(id) + assertEquals(held.split?.shareOf(bob)?.amount, result.split?.shareOf(bob)?.amount) + } + + @Test + fun `a payment replaces a hand mark on the same share`() { + val manual = PaidShare(Instant.fromEpochSeconds(100), PaidShare.How.Manual) + val held = request(3, listOf(share(alice, manual), share(bob))) + val result = apply(held, updates(4, Change.SharePaid(alice, paid))).requests.getValue(id) + assertEquals(paid, result.split?.shareOf(alice)?.paid) + } + + @Test + fun `a status change sets status and closedAt`() { + val out = apply(request(3), updates(4, Change.StatusChanged(PaymentRequestStatus.Closed, closedAt))) + val result = out.requests.getValue(id) + assertEquals(PaymentRequestStatus.Closed, result.status) + assertEquals(closedAt, result.closedAt) + assertEquals(4, result.version) + } + + @Test + fun `the last share paid and completion apply together in order`() { + val held = request(3, listOf(share(alice, paid), share(bob))) + val out = apply( + held, + updates(4, Change.SharePaid(bob, paid), Change.StatusChanged(PaymentRequestStatus.Completed, closedAt)), + ) + val result = out.requests.getValue(id) + assertTrue(result.split!!.shares.all { it.isPaid }) + assertEquals(PaymentRequestStatus.Completed, result.status) + assertEquals(closedAt, result.closedAt) + } + + @Test + fun `an update at the held version is ignored`() { + val held = request(4) + val out = apply(held, updates(4, Change.StatusChanged(PaymentRequestStatus.Closed, closedAt))) + assertEquals(mapOf(id to held), out.requests) + assertFalse(out.refetch) + } + + @Test + fun `an update below the held version is ignored even when it has an unknown change`() { + val held = request(5) + val out = apply(held, updates(3, Change.Unknown)) + assertEquals(mapOf(id to held), out.requests) + assertFalse(out.refetch) + } + + @Test + fun `an update delivered twice applies once`() { + val update = updates(4, Change.SharePaid(alice, paid)) + val once = apply(request(3), update) + val twice = PaymentRequests.applyUpdate(once.requests, update) + assertEquals(once.requests, twice.requests) + assertFalse(twice.refetch) + } + + @Test + fun `an update past the next version leaves the copy and asks for a refetch`() { + val held = request(3) + val out = apply(held, updates(5, Change.SharePaid(alice, paid))) + assertEquals(mapOf(id to held), out.requests) + assertTrue(out.refetch) + } + + @Test + fun `an out-of-order update is refetched, and the earlier one then ignored`() { + val held = request(3) + val late = updates(5, Change.StatusChanged(PaymentRequestStatus.Closed, closedAt)) + assertTrue(apply(held, late).refetch) + // The refetch landed at version 5; the update for 4 that shows up afterwards is stale. + val fetched = request(5) + assertFalse(PaymentRequests.applyUpdate(mapOf(id to fetched), updates(4, Change.SharePaid(alice, paid))).refetch) + } + + @Test + fun `no held copy ignores the update without a refetch`() { + val out = apply(null, updates(4, Change.SharePaid(alice, paid))) + assertTrue(out.requests.isEmpty()) + assertFalse(out.refetch) + } + + @Test + fun `an unknown change applies none of the batch and refetches`() { + val held = request(3) + val out = apply(held, updates(4, Change.SharePaid(alice, paid), Change.Unknown)) + assertEquals(mapOf(id to held), out.requests) + assertTrue(out.refetch) + } + + @Test + fun `a share the copy does not have applies none of the batch and refetches`() { + val held = request(3) + val stranger = List(16) { 7 } + val out = apply( + held, + updates(4, Change.SharePaid(alice, paid), Change.SharePaid(stranger, paid)), + ) + assertEquals(mapOf(id to held), out.requests) + assertTrue(out.refetch) + } + + @Test + fun `an unknown update kind refetches a held request and ignores an absent one`() { + val unknown = PaymentRequestUpdate(id, PaymentRequestUpdate.Kind.Unknown) + val held = request(3) + assertTrue(apply(held, unknown).refetch) + assertEquals(mapOf(id to held), apply(held, unknown).requests) + assertFalse(apply(null, unknown).refetch) + } + + @Test + fun `a created request merges like a fetched copy`() { + // Created goes through merge: the greater version wins, EXPIRED over OPEN at a tie. + val held = mapOf(id to request(5)) + assertEquals(held, PaymentRequests.merge(held, request(4))) + assertEquals(6, PaymentRequests.merge(held, request(6))[id]?.version) + val expired = request(5).copy(status = PaymentRequestStatus.Expired) + assertEquals(PaymentRequestStatus.Expired, PaymentRequests.merge(mapOf(id to expired), request(5))[id]?.status) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 57058311a4..8b624c1a8a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -12,6 +12,23 @@ import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.app.persistence.entities.MessageStatus import kotlinx.coroutines.flow.Flow +/** + * Excludes a cash message that pays a payment request share: the transcript draws no bubble for it + * (`MessageContent.paysRequestShare`), so it is not a preview, an unread message or an arrival. + * + * `content_json` is serialized with defaults, so a cash entry always carries `"paymentRequestId"`: + * `null` unless it paid a share. The widget that shows a request carries the same key under another + * type, hence the `"cash"` test. An unescaped `"` cannot come from message text, which is escaped. + */ +private const val NOT_REQUEST_SHARE = + "AND NOT (COALESCE(content_json, '') LIKE '%\"type\":\"cash\"%' " + + "AND COALESCE(content_json, '') LIKE '%\"paymentRequestId\":\"%') " + +/** [NOT_REQUEST_SHARE] for the correlated subqueries that alias the table as `m`. */ +private const val NOT_REQUEST_SHARE_M = + "AND NOT (COALESCE(m.content_json, '') LIKE '%\"type\":\"cash\"%' " + + "AND COALESCE(m.content_json, '') LIKE '%\"paymentRequestId\":\"%') " + @Dao interface ChatMessageDao { @@ -57,7 +74,7 @@ interface ChatMessageDao { * the newest id including tombstones, or a delete would regress the read pointer and leave the * chat unread forever. */ - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 AND content_json IS NOT NULL AND content_json != '[]' AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 AND content_json IS NOT NULL AND content_json != '[]' AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') " + NOT_REQUEST_SHARE + "ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") suspend fun getLatestVisible(chatIdHex: String): ChatMessageEntity? /** @@ -73,6 +90,7 @@ interface ChatMessageDao { "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + "AND m.content_json IS NOT NULL AND m.content_json != '[]' " + "AND (m.encryption_state IS NULL OR m.encryption_state != 'KEY_PENDING') " + + NOT_REQUEST_SHARE_M + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" ) @@ -89,6 +107,7 @@ interface ChatMessageDao { "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + "AND m.content_json IS NOT NULL AND m.content_json != '[]' " + "AND (m.encryption_state IS NULL OR m.encryption_state != 'KEY_PENDING') " + + NOT_REQUEST_SHARE_M + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" ) @@ -117,7 +136,8 @@ interface ChatMessageDao { "SELECT COUNT(*) FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex " + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + - "AND is_deleted = 0 AND message_id > :afterId" + "AND is_deleted = 0 AND message_id > :afterId " + + NOT_REQUEST_SHARE ) suspend fun countInboundAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Int @@ -129,7 +149,8 @@ interface ChatMessageDao { "SELECT COUNT(*) FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex " + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + - "AND is_deleted = 0 AND message_id > :afterId" + "AND is_deleted = 0 AND message_id > :afterId " + + NOT_REQUEST_SHARE ) fun observeInboundCountAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Flow @@ -141,7 +162,8 @@ interface ChatMessageDao { "SELECT MAX(message_id) FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex " + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + - "AND is_deleted = 0" + "AND is_deleted = 0 " + + NOT_REQUEST_SHARE ) suspend fun newestInboundId(chatIdHex: String, selfIdHex: String): Long? @@ -156,7 +178,8 @@ interface ChatMessageDao { @Query( "SELECT MIN(message_id) FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex AND message_id > :afterId " + - "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex" + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + + NOT_REQUEST_SHARE ) suspend fun firstNotSentByAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Long? diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 94c520e0b5..4cfa6c5d3d 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -978,7 +978,7 @@ class ChatMessageDaoTest { fun `firstNotSentByAfter skips a senderless row`() = runTest { dao.upsert(listOf(from(SENDER_HEX, 1), text(2, "system").copy(senderIdHex = null), from(SENDER_HEX, 3))) - assertEquals(3L, firstNotOwn(pointer = 1)) + assertEquals(3L, dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1)) } @Test @@ -1000,7 +1000,47 @@ class ChatMessageDaoTest { fun `the divider lands below a senderless row that follows the pointer`() = runTest { dao.upsert(listOf(from(SELF_HEX, 1), text(2, "system").copy(senderIdHex = null), from(SENDER_HEX, 3))) - assertEquals(3L, firstNotOwn(pointer = 1)) + assertEquals(3L, dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1)) assertEquals(1, unread(pointer = 1)) } + + private fun cash(messageId: Long, paymentRequestId: String?) = from(SENDER_HEX, messageId).copy( + contentJson = listOf( + MessageContentSerialized.Cash(intentId = "intent", quarks = 5, paymentRequestId = paymentRequestId), + ), + ) + + private fun requestWidget(messageId: Long) = from(SENDER_HEX, messageId).copy( + contentJson = listOf(MessageContentSerialized.WidgetPaymentRequest(paymentRequestId = "req")), + ) + + @Test + fun `the preview falls back past a cash message that pays a request share`() = runTest { + dao.upsert(listOf(text(1, "one"), cash(2, paymentRequestId = "req"))) + + assertEquals(1L, dao.getLatestVisible(CHAT_HEX)?.messageId) + assertEquals(listOf(1L), dao.getLatestVisibleForAllChats().map { it.messageId }) + assertEquals(listOf(1L), dao.observeLatestVisibleForAllChats().first().map { it.messageId }) + // The anchors that mark read still see it, so the pointer is never rewound. + assertEquals(2L, dao.getLatest(CHAT_HEX)?.messageId) + } + + @Test + fun `a cash message without a request id and a request widget are still previewed`() = runTest { + dao.upsert(listOf(text(1, "one"), cash(2, paymentRequestId = null))) + assertEquals(2L, dao.getLatestVisible(CHAT_HEX)?.messageId) + + dao.upsert(listOf(requestWidget(3))) + assertEquals(3L, dao.getLatestVisible(CHAT_HEX)?.messageId) + } + + @Test + fun `a request share payment is not unread, an arrival or the divider's message`() = runTest { + dao.upsert(listOf(from(SELF_HEX, 1), cash(2, paymentRequestId = "req"), cash(3, paymentRequestId = null))) + + assertEquals(1, unread(pointer = 1)) + assertEquals(3L, dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1)) + assertEquals(3L, dao.newestInboundId(CHAT_HEX, SELF_HEX)) + assertEquals(1, dao.observeInboundCountAfter(CHAT_HEX, SELF_HEX, 1).first()) + } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt index 4144a5a395..655120a51f 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt @@ -71,8 +71,8 @@ class MetadataMapper @Inject constructor(): Mapper MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction) - is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction) + is NotificationMetadata.DirectlySentCrypto -> MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment) + is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment) is NotificationMetadata.IndirectlySentCrypto -> MessageMetadata.IndirectlySentCrypto(from.creator, from.canCancel) NotificationMetadata.Unknown -> MessageMetadata.Unknown is NotificationMetadata.WithdrewCrypto -> MessageMetadata.WithdrewCrypto(from.swapMetadata?.toMessage()) diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt index 088a82705b..70abad3b99 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapperTest.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.NotificationState +import com.flipcash.services.models.RequestedPayment import com.flipcash.services.models.Substitution import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -159,4 +160,37 @@ class NotificationToEntityMapperTest { assertEquals("+15551234567", received.phoneNumber) assertNull(received.boostedReaction) } + + // --- Requested payment persistence: rides the same `metadata` JSON column. --- + + private val requested = RequestedPayment( + chatId = ChatId(ByteArray(32) { (it + 9).toByte() }), + paymentRequestId = List(16) { it.toByte() }, + ) + + @Test + fun `a sent payment of a requested share reads back with its chat and request`() { + val read = roundTrip( + NotificationMetadata.DirectlySentCrypto(userId = listOf(0x0A), requestedPayment = requested) + ) as MessageMetadata.DirectlySentCrypto + + assertEquals(requested, read.requestedPayment) + assertNull(read.boostedReaction) + } + + @Test + fun `a received payment of a requested share reads back with its chat and request`() { + val read = roundTrip( + NotificationMetadata.ReceivedCrypto(userId = listOf(0x0A), requestedPayment = requested) + ) as MessageMetadata.ReceivedCrypto + + assertEquals(requested, read.requestedPayment) + } + + @Test + fun `a payment without a request reads back null`() { + val read = roundTrip(NotificationMetadata.DirectlySentCrypto(userId = listOf(0x0A))) + as MessageMetadata.DirectlySentCrypto + assertNull(read.requestedPayment) + } } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt index d03a8bd5cc..9cf55b2f5f 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetails.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.transactionhistory import androidx.annotation.StringRes import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token @@ -111,6 +112,9 @@ enum class TransactionStatus(@StringRes val labelRes: Int) { * @param boostedReaction The reaction this payment boosted, when it did. Its presence swaps the * chat button for "View Message", which opens the boosted message — and shows it even on a split * boost, which has no counterparty for [canViewInChat] to open. + * @param requestedPayment The payment request this payment paid a share of, when it did. Swaps the + * chat button for "View Request" the way [boostedReaction] does; a boost wins if both are set. A + * snapshot of what the payment was for, not kept current. */ data class TransactionDetails( val id: String, @@ -132,4 +136,5 @@ data class TransactionDetails( val canCancel: Boolean = false, val canViewInChat: Boolean = false, val boostedReaction: BoostedReaction? = null, + val requestedPayment: RequestedPayment? = null, ) diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt index c1aca2644a..ff03c9ade5 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsContent.kt @@ -64,6 +64,8 @@ fun TransactionDetailsContent( onViewInChat: () -> Unit = { }, /** Opens the boosted message in its chat; the button shows whenever the entry boosted one. */ onViewMessage: () -> Unit = { }, + /** Opens the request this payment paid a share of; shown when there is no boost. */ + onViewRequest: () -> Unit = { }, /** The button opens the counterparty's profile rather than a chat, because no DM exists yet. */ viewsProfile: Boolean = false, onCancel: () -> Unit = { }, @@ -114,6 +116,14 @@ fun TransactionDetailsContent( shape = CodeTheme.shapes.extraSmall, onClick = onViewMessage, ) + } else if (details.requestedPayment != null) { + CodeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.action_txnDetails_viewRequest), + buttonState = ButtonState.Filled05, + shape = CodeTheme.shapes.extraSmall, + onClick = onViewRequest, + ) } else if (details.canViewInChat) { CodeButton( modifier = Modifier.fillMaxWidth(), diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt index 42e6e0e167..feaccece0e 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionDetailsMapper.kt @@ -105,6 +105,7 @@ internal class TransactionDetailsMapper @Inject constructor( // open it with. canViewInChat = counterparty != null, boostedReaction = boost, + requestedPayment = requestedPaymentOf(meta), ) } diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt index 9d84abe316..3c21e005bf 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -4,6 +4,7 @@ import com.flipcash.app.core.feed.ActivityFeedMessageWithToken import com.flipcash.app.core.feed.MessageMetadata import com.flipcash.app.core.feed.MessageSubstitution import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.flipcash.services.models.UserProfile import com.flipcash.shared.transactionhistory.R import com.flipcash.shared.transactionhistory.TransactionAvatar @@ -124,6 +125,12 @@ internal fun boostedReactionOf(meta: MessageMetadata?): BoostedReaction? = when else -> null } +internal fun requestedPaymentOf(meta: MessageMetadata?): RequestedPayment? = when (meta) { + is MessageMetadata.DirectlySentCrypto -> meta.requestedPayment + is MessageMetadata.ReceivedCrypto -> meta.requestedPayment + else -> null +} + /** A boost that paid both the message's sender and the group's creator names neither. */ internal fun isSplitBoost(meta: MessageMetadata?): Boolean = boostedReactionOf(meta) != null && userIdOf(meta) == null && when (meta) { diff --git a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml index 74c6f65147..a786d72cd5 100644 --- a/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/transaction-history/src/main/res/values/strings.xml @@ -64,6 +64,7 @@ View in Chat View Message + View Request View Profile Copy transaction ID Cancel diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt index 10d622a221..770de4afe8 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionDetailsMapperTest.kt @@ -7,6 +7,7 @@ import com.flipcash.app.core.feed.MessageState import com.flipcash.app.core.feed.SwapState import com.flipcash.app.core.feed.SwappedCryptoMetadata import com.flipcash.services.models.BoostedReaction +import com.flipcash.services.models.RequestedPayment import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.Emoji @@ -395,4 +396,27 @@ class TransactionDetailsMapperTest { assertNull(d.subtitle) assertEquals(TransactionKind.Sent, d.kind) } + + private val requested = RequestedPayment( + chatId = ChatId(ByteArray(32) { 4 }), + paymentRequestId = List(16) { it.toByte() }, + ) + + @Test + fun `a sent payment of a requested share carries the request`() { + val details = map(MessageMetadata.DirectlySentCrypto(userId = knownUserId, requestedPayment = requested)) + assertEquals(requested, details.requestedPayment) + assertNull(details.boostedReaction) + } + + @Test + fun `a received payment of a requested share carries the request`() { + val details = map(MessageMetadata.ReceivedCrypto(userId = knownUserId, requestedPayment = requested)) + assertEquals(requested, details.requestedPayment) + } + + @Test + fun `a payment with no request carries none`() { + assertNull(map(MessageMetadata.DirectlySentCrypto(userId = knownUserId)).requestedPayment) + } }