From 05c5df48f930f1c959d78f49dffae8320ea1da8f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 20:19:25 -0400 Subject: [PATCH 1/3] feat(chat): give a link card its own row A carded link drew inside the message's bubble, with the URL cut out of the text around it. The message now splits into up to three transcript rows, as on iOS (code-ios-app#830): the text before the link, the card with no bubble behind it, and the text after. Blank segments are dropped, and the rows key as the message id plus `#leading`, `#card` or `#trailing`. Every row keeps the whole message, so selection, copy, edit, reply, retry, jumps and the jump highlight act on the message from whichever row they start on; selection now compares `messageKey` instead of the row key. The reply citation goes on the first row and the "Edited" marker and receipt on the last. A card row breaks the bubble run the way a bare emoji does, and its "Edited" marker moves to the metadata line under it. The view model emits the rows bottom-first because the list is reverseLayout. LinkCardView now owns the card's tap and hands a long press back to the transcript, which the bare row needs to stay selectable. `withoutLinkSpan` and its test go, since no row cuts the link out of its text any more. --- .../app/messenger/internal/ChatViewModel.kt | 12 +- .../internal/screens/components/FocusPin.kt | 7 +- .../screens/components/MessageList.kt | 3 +- .../internal/screens/components/MessageRow.kt | 15 +- .../screens/components/ReceiptRules.kt | 3 + .../shared/chat/models/ChatListItem.kt | 90 ++++++- .../flipcash/shared/chat/ui/LinkCardView.kt | 61 +++-- .../flipcash/shared/chat/ui/LinkDetection.kt | 32 --- .../flipcash/shared/chat/ui/MessageBubble.kt | 253 +++++++++++------- .../models/ChatListItemContentTypeTest.kt | 4 +- .../chat/models/SplitAroundLinkCardTest.kt | 122 +++++++++ .../shared/chat/ui/BubblePositionTest.kt | 32 +++ .../shared/chat/ui/LinkSpanRemovalTest.kt | 57 ---- 13 files changed, 452 insertions(+), 239 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt delete mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkSpanRemovalTest.kt 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 f48f6dc9bf..de8205c6a3 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 @@ -69,6 +69,7 @@ import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.LinkCardResolution import com.flipcash.shared.chat.models.ReceiptStatus +import com.flipcash.shared.chat.models.splitAroundLinkCard import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.resolveCapabilities @@ -589,7 +590,7 @@ internal class ChatViewModel @Inject constructor( ) { pagingData, mutations, policy, profiles -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) - message.content.mapIndexed { index, content -> + message.content.flatMapIndexed { index, content -> val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) { val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token if (token != null) { @@ -679,6 +680,11 @@ internal class ChatViewModel @Inject constructor( senderId = message.senderId?.takeIf { !message.isFromSelf }, linkCard = linkCard, ) + // A carded link takes a row of its own, with the prose either side of it + // on rows above and below. Reversed because the list is: this page runs + // newest-first under reverseLayout, so the row drawn lowest goes first. + .splitAroundLinkCard() + .asReversed() } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> if (before == null || after == null) return@insertSeparators null @@ -2145,7 +2151,9 @@ internal class ChatViewModel @Inject constructor( is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) } is Event.MessagePolicyChanged -> { state -> state.copy(messagePolicy = event.policy) } is Event.ToggleMessageSelection -> { state -> - val alreadySelected = state.selection?.itemKey == event.bubble.itemKey + // By message rather than by row: a message split around its card is one + // selection whichever of its rows was pressed. + val alreadySelected = state.selection?.messageKey == event.bubble.messageKey // The transcript resolved this bubble when it was mapped, which may have been // well inside a window that has since closed. Narrow it again here so the bar // offers what is open now rather than what was open when the row was built. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt index bc5b02dac2..5944e1c822 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt @@ -209,6 +209,11 @@ internal fun LazyListLayoutInfo.headroomAbove( messages: LazyPagingItems, messageId: Long, ): Int { - val row = rowFor(messages, messageId) ?: return 0 + // The topmost row of the message, which is the one that can be under the bar: a message split + // around its card is several rows, and rowFor answers with the lowest. + val row = visibleItemsInfo.lastOrNull { info -> + info.index < messages.itemCount && + (messages.peek(info.index) as? ChatListItem.ContentBubble)?.messageId == messageId + } ?: return 0 return viewportEndOffset - afterContentPadding - (row.offset + row.size) } 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 54016ca917..3cc6b4208a 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 @@ -284,7 +284,8 @@ internal fun MessageList( // half-clipped where the sheet cuts across it. state.confirmingDelete -> false state.editing != null -> bubble?.messageId == state.editing.messageId - state.selection != null -> bubble?.itemKey == state.selection.itemKey + // Every row of the message stays sharp, so a split message is focused whole. + state.selection != null -> bubble?.messageKey == state.selection.messageKey else -> true } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 2deaab1151..c4a57e140d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -54,7 +54,7 @@ import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.features.messenger.R import com.flipcash.shared.chat.ui.ContentBubble -import com.flipcash.shared.chat.ui.rendersBareEmoji +import com.flipcash.shared.chat.ui.rendersBare import com.flipcash.shared.common.ui.ContactAvatar import com.flipcash.shared.chat.ui.bubblePositionOf import com.getcode.theme.CodeTheme @@ -310,13 +310,14 @@ internal fun MessageRow( } val showReceipt = shouldShowReceiptLabel(index, item, messages, otherReadPointer) - // An emoji-only message has no bubble, so its "Edited" marker has nowhere - // to sit inside the message and comes out here instead — on the same line - // as the receipt, and ahead of it, so the two read as one trailing note. - // It stands alone on rows that carry no receipt, which is every incoming - // one. + // An emoji-only message, or a card row, has no bubble, so its "Edited" + // marker has nowhere to sit inside the message and comes out here + // instead — on the same line as the receipt, and ahead of it, so the two + // read as one trailing note. It stands alone on rows that carry no + // receipt, which is every incoming one. A split message marks only its + // last row. Row(verticalAlignment = Alignment.Top) { - if (item.isEdited && item.rendersBareEmoji()) { + if (item.isEdited && item.isLastRow && item.rendersBare()) { Text( modifier = Modifier.padding( top = CodeTheme.dimens.grid.x1, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt index f003421fb3..c47be1adbf 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt @@ -54,6 +54,9 @@ internal fun shouldShowReceiptLabel( otherReadPointer: MessagePointer?, ): Boolean { if (!item.carriesReceipt) return false + // A split message's receipt goes under its last row only. The rows above share its status, so + // the rules below would already hide them -- except a failure, which shows everywhere it can. + if (!item.isLastRow) return false val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false if (status == ReceiptStatus.FAILED) return true if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 49a1facf11..94a48db35a 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -63,6 +63,27 @@ sealed interface ChatListItem { * so early on every member's bubble has a null [sender] and two members read as one author. */ val senderId: ID? = null, + /** + * Which row of a message split around its link card this is, or `null` for a row that is + * the whole message. See [splitAroundLinkCard]. + * + * Every row of one message carries the same [messageId], [content] and [capabilities], so + * the selection bar, copy, edit, reply, retry and a jump act on the message from whichever + * row they start on. + */ + val part: MessagePart? = null, + /** + * The text a [MessagePart.Leading] or [MessagePart.Trailing] row draws. [content] keeps the + * whole message, because that is what Copy and Edit act on. + */ + val partText: String? = null, + /** Whether this is the topmost row of its message, which is where a reply's citation goes. */ + val isFirstRow: Boolean = true, + /** + * Whether this is the bottommost row of its message, which is where the "Edited" marker and + * the receipt go. + */ + val isLastRow: Boolean = true, ) : ChatListItem { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the @@ -105,7 +126,14 @@ sealed interface ChatListItem { val isSelectable: Boolean get() = capabilities.isNotEmpty() - override val itemKey: Any = pendingClientIdHex ?: "$messageId-$contentIndex" + /** + * The message this row draws, shared by every row it is split into. Selection compares on + * this rather than on [itemKey], so the whole message stays sharp behind the backdrop. + */ + val messageKey: Any get() = pendingClientIdHex ?: "$messageId-$contentIndex" + + override val itemKey: Any = (pendingClientIdHex ?: "$messageId-$contentIndex") + .let { key -> part?.let { "$key#${it.id}" } ?: key } // A tombstone shares the text bubble's content type on purpose: deleting a message is an // in-place update of a row the list already holds, and giving it a type of its own would @@ -116,7 +144,7 @@ sealed interface ChatListItem { // slot to text. Deleting one does drop and re-insert the row, which is right -- the // card has to go, and there is no in-place update that removes it. override val itemContentType: Any = when { - linkCard != null -> "link-card-bubble" + part == MessagePart.Card -> "link-card-bubble" else -> when (content) { is MessageContent.Text -> "text-bubble" is MessageContent.Deleted -> "text-bubble" @@ -128,3 +156,61 @@ sealed interface ChatListItem { } } } + +/** + * One row of a message the transcript split around its link card, in the order the sender wrote + * them. [id] is the suffix on the row's [ChatListItem.itemKey]; iOS uses the same three. + */ +enum class MessagePart(val id: String) { + /** The text before the link. */ + Leading("leading"), + + /** The card, drawn bare. */ + Card("card"), + + /** The text after the link. */ + Trailing("trailing"), +} + +/** + * The rows this bubble draws, top to bottom: the text before its card, the card, and the text after + * it, each dropped when it would hold nothing but whitespace. A bubble with no card is one row, as + * it always was. + * + * The whitespace on either side of the link goes with it -- it was the gap around a word that now + * has a row of its own. Other links stay in whichever text row they fell in, underlined. + * + * The reply citation goes on the first row and the "Edited" marker and receipt on the last, so a + * split message reads as one message stacked in three pieces rather than as three messages. + * + * A card whose span does not fit the text is dropped rather than split, and the message renders as + * text with its links underlined. + */ +fun ChatListItem.ContentBubble.splitAroundLinkCard(): List { + val card = linkCard ?: return listOf(this) + val text = plainText ?: return listOf(copy(linkCard = null)) + if (card.start < 0 || card.end > text.length || card.start >= card.end) { + return listOf(copy(linkCard = null)) + } + + var leadingEnd = card.start + while (leadingEnd > 0 && text[leadingEnd - 1].isWhitespace()) leadingEnd-- + var trailingStart = card.end + while (trailingStart < text.length && text[trailingStart].isWhitespace()) trailingStart++ + + val parts = listOfNotNull( + text.substring(0, leadingEnd).takeIf { it.isNotBlank() }?.let { MessagePart.Leading to it }, + MessagePart.Card to null, + text.substring(trailingStart).takeIf { it.isNotBlank() }?.let { MessagePart.Trailing to it }, + ) + + return parts.mapIndexed { index, (part, segment) -> + copy( + part = part, + partText = segment, + linkCard = card.takeIf { part == MessagePart.Card }, + isFirstRow = index == 0, + isLastRow = index == parts.lastIndex, + ) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 0262474f4a..92f7b0fb9b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -2,7 +2,7 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -56,6 +56,7 @@ import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme import com.getcode.ui.components.text.AnimatedNumberText +import com.getcode.ui.core.addIf import com.getcode.ui.utils.ConstraintMode /** @@ -67,10 +68,14 @@ import com.getcode.ui.utils.ConstraintMode * the link opens to. The split is the point: a voucher for $15 and a card about Dollars should not * be the same gold rectangle, so the bill's colours stay with the card that stands for the token. * - * The URL the card came from is cut from the body text, so the card is the link rather than an - * ornament above it, so [onClick] has to carry the tap — a link-only message would otherwise draw - * something that opens nothing. The whole card is handed back rather than its URL, because where a - * tap should land differs by kind and only the caller knows the transcript it is landing in. + * The card sits on a row of its own with the URL left out of the text rows either side, so the card + * is the link rather than an ornament beside it, and [onClick] has to carry the tap — a link-only + * message would otherwise draw something that opens nothing. The whole card is handed back rather + * than its URL, because where a tap should land differs by kind and only the caller knows the + * transcript it is landing in. Null leaves the card inert, which is what it is with the backdrop up. + * + * The card takes the press for its tap, so the transcript's long press has to come through here + * too as [onLongClick], or pressing a card would select nothing. * * [card] arrives with its lookup still to do and the card runs it — see [rememberResolvedCard]. * The transcript hands over what it can read off the message text, which is everything but the @@ -79,28 +84,34 @@ import com.getcode.ui.utils.ConstraintMode @Composable internal fun LinkCardView( card: LinkCard, - onClick: (LinkCard) -> Unit, + onClick: ((LinkCard) -> Unit)?, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, ) { val live = rememberResolvedCard(card) + val shape = CodeTheme.shapes.medium // The voucher's proportions, not its size: a bubble is a good deal narrower than the wallet's // card, and scaling the height with the width is what keeps it a card in chat instead of a // tall panel. - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + // One target for the whole card in every state: an unresolved link is unresolved, not + // broken, and a spent one still opens to the page that says so. + .addIf(onClick != null || onLongClick != null) { + Modifier + .clip(shape) + .combinedClickable( + onLongClick = onLongClick, + onClick = { onClick?.invoke(live) }, + ) + }, + ) { val height: Dp = maxWidth * LinkCardDefaults.CARD_ASPECT when (live) { - is LinkCard.Cash -> CashLinkCard( - card = live, - height = height, - onClick = { onClick(live) }, - ) - - is LinkCard.TokenInfo -> TokenLinkCard( - card = live, - height = height, - onClick = { onClick(live) }, - ) + is LinkCard.Cash -> CashLinkCard(card = live, height = height) + is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height) } } } @@ -147,7 +158,6 @@ private fun rememberResolvedCard(card: LinkCard): LinkCard { private fun CashLinkCard( card: LinkCard.Cash, height: Dp, - onClick: () -> Unit, ) { // Unresolved is also the unavailable state: a lookup that failed, timed out or was switched off // renders here. The same voucher, with nothing filled in — same size, same chrome — so nothing @@ -175,7 +185,6 @@ private fun CashLinkCard( // Only claimed, because only claimed means someone tore it off. An expired link lapsed // where it sat; drawing it torn would say a person acted on it when nobody did. torn = state?.claim == LinkCard.Cash.Claim.Claimed, - onClick = onClick, ) { if (state == null) { // The tap is live before the lookup is — the whole voucher is clickable in every state, @@ -248,7 +257,6 @@ private fun TokenRow(tokenName: String, tokenImage: Any?) { private fun TokenLinkCard( card: LinkCard.TokenInfo, height: Dp, - onClick: () -> Unit, ) { when (val state = card.state) { is LinkCard.TokenInfo.State.Resolved -> TokenCard( @@ -258,7 +266,6 @@ private fun TokenLinkCard( balanceText = "", displayName = state.token.brandedName(), height = height, - onClick = onClick, ) // The address is the one thing known about the mint before the network answers, and it is @@ -268,14 +275,12 @@ private fun TokenLinkCard( mint = card.mint, height = height, loading = true, - onClick = onClick, ) LinkCard.TokenInfo.State.Unresolved -> UnresolvedTokenCard( mint = card.mint, height = height, loading = false, - onClick = onClick, ) } } @@ -294,7 +299,6 @@ private fun UnresolvedTokenCard( mint: Mint, height: Dp, loading: Boolean, - onClick: () -> Unit, ) { val shape = CodeTheme.shapes.medium Box( @@ -307,7 +311,6 @@ private fun UnresolvedTokenCard( // and the one thing already known about the token stays readable while it does. .then(if (loading) Modifier.shimmer(shape) else Modifier) .border(CodeTheme.dimens.border, CodeTheme.colors.surfaceVariant, shape) - .clickable(onClick = onClick) .padding(CodeTheme.dimens.inset), ) { Text( @@ -355,7 +358,6 @@ private fun CashVoucher( spent: Boolean, torn: Boolean, loading: Boolean, - onClick: () -> Unit, stub: @Composable () -> Unit, ) { val corner = CodeTheme.shapes.medium.topStart @@ -366,10 +368,7 @@ private fun CashVoucher( Column( modifier = Modifier .fillMaxWidth() - .height(height) - // Tappable in every state: an unresolved link is unresolved, not broken, and a spent - // one still opens to the page that says so. - .clickable(onClick = onClick), + .height(height), ) { VoucherPiece( modifier = Modifier.weight(1f), diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt index 146d518896..c47a79d5e9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt @@ -85,35 +85,3 @@ fun MessageContent.linkableText(): String? = when (this) { is MessageContent.System, -> null } - -/** - * This text with the half-open span `[start, end)` cut out and the gap it left closed up. - * - * Used where a link renders as a card: the card *is* the link, so leaving the URL in the body - * below it says the same thing twice. What surrounds the link is ordinary prose, and cutting a - * word out of a sentence leaves the spaces on either side of it behind — "check example.com out" - * would render as "check out". So the whitespace the cut orphaned goes with it, and the two - * halves are rejoined by a single separator: a newline if either side had one, a space otherwise. - * A message that was nothing but the link comes back empty, and the bubble draws the card alone. - * - * Out-of-range bounds return the text unchanged rather than throwing. The span is measured on one - * pass over the message and used on another, and a body that quietly keeps its link is a better - * failure than a transcript that crashes on one message. - */ -fun String.withoutLinkSpan(start: Int, end: Int): String { - if (start < 0 || end > length || start > end) return this - - val leading = substring(0, start) - val trailing = substring(end) - val head = leading.trimEnd() - val tail = trailing.trimStart() - - return when { - head.isEmpty() -> tail - tail.isEmpty() -> head - else -> { - val gap = leading.substring(head.length) + trailing.substring(0, trailing.length - tail.length) - head + (if (gap.contains('\n')) "\n" else " ") + tail - } - } -} 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 60cee75f5d..a573829fea 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 @@ -64,6 +64,9 @@ import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.models.MessagePart +import com.flipcash.shared.chat.models.splitAroundLinkCard +import kotlin.time.Instant import com.flipcash.shared.chat.models.SeparatorConfig import com.getcode.opencode.compose.ExchangeStub import com.getcode.opencode.compose.LocalExchange @@ -112,20 +115,47 @@ fun ContentBubble( is MessageContent.System -> maxWidth } + // A split message's citation goes on its first row and its marker on its last, so the + // pieces read as one message stacked rather than as three. + val quote = item.quote?.takeIf { item.isFirstRow } + val onQuoteClick = quote?.takeIf { interactive }?.let { cited -> + { actionHandler(ChatAction.JumpToMessage(cited.messageId)) } + } + val isEdited = item.isEdited && item.isLastRow + val card = item.linkCard?.takeIf { item.part == MessagePart.Card } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (item.isFromSelf) Arrangement.End else Arrangement.Start, ) { + // The card row of a split message, bare whatever the message around it carries -- a + // text or a reply alike. The rows either side of it hold the prose. + if (card != null) { + BareLinkCard( + modifier = modifier, + card = card, + isFromSelf = item.isFromSelf, + position = position, + maxWidth = bubbleMaxWidth, + quote = quote, + onQuoteClick = onQuoteClick, + onQuoteLongClick = onLongClick?.takeIf { interactive }, + onLongClick = onLongClick?.takeIf { interactive }, + interactive = interactive, + attention = attention, + ) + return@Row + } + when (val content = item.content) { is MessageContent.Text -> TextBubble( modifier = modifier, - text = content.text, + text = item.partText ?: content.text, isFromSelf = item.isFromSelf, position = position, maxWidth = bubbleMaxWidth, - isEdited = item.isEdited, + isEdited = isEdited, jumbo = jumbo, - linkCard = item.linkCard, attention = attention, ) @@ -175,18 +205,15 @@ fun ContentBubble( // identical to any other message, which is what it is. is MessageContent.Reply -> TextBubble( modifier = modifier, - text = content.linkableText().orEmpty(), + text = item.partText ?: content.linkableText().orEmpty(), isFromSelf = item.isFromSelf, position = position, maxWidth = bubbleMaxWidth, - isEdited = item.isEdited, - quote = item.quote, - linkCard = item.linkCard, + isEdited = isEdited, + quote = quote, // Dropped with the backdrop up, as the cash bubble's target is: the tap // should dismiss the backdrop, not jump the transcript out from under it. - onQuoteClick = item.quote?.takeIf { interactive }?.let { quote -> - { actionHandler(ChatAction.JumpToMessage(quote.messageId)) } - }, + onQuoteClick = onQuoteClick, onQuoteLongClick = onLongClick?.takeIf { interactive }, jumbo = jumbo, attention = attention, @@ -212,9 +239,12 @@ fun ContentBubble( * beneath it. */ fun ChatListItem.ContentBubble.rendersBareEmoji(): Boolean { + if (part == MessagePart.Card) return false val text = when (val content = content) { is MessageContent.Text -> content.text - is MessageContent.Reply -> if (quote != null) { + // Only the row that draws the citation keeps its bubble for it; the other rows of a split + // reply are text like any other. + is MessageContent.Reply -> if (quote != null && isFirstRow) { return false } else { content.content.filterIsInstance().firstOrNull()?.text.orEmpty() @@ -222,11 +252,20 @@ fun ChatListItem.ContentBubble.rendersBareEmoji(): Boolean { else -> return false } - return EmojiOnlyText.clusterCountOrNull(text) != null + return EmojiOnlyText.clusterCountOrNull(partText ?: text) != null } +/** + * Whether this row draws with no bubble behind it: a bare emoji, or the card row of a split + * message. Either one breaks the bubble run and moves the "Edited" marker to the line under it. + */ +fun ChatListItem.ContentBubble.rendersBare(): Boolean = + part == MessagePart.Card || rendersBareEmoji() + private const val EDITED_MARKER_SLOT = "edited-marker" +internal const val REPLY_QUOTE_TAG = "bubble_reply_quote" + /** How white a bubble goes at the peak of the flash a jump leaves on it. */ private const val ATTENTION_SCRIM_ALPHA = 0.14f @@ -278,7 +317,6 @@ private fun TextBubble( isEdited: Boolean = false, isTombstone: Boolean = false, quote: ChatQuote? = null, - linkCard: LinkCard? = null, onQuoteClick: (() -> Unit)? = null, onQuoteLongClick: (() -> Unit)? = null, jumbo: Boolean = false, @@ -296,30 +334,7 @@ private fun TextBubble( return } - // The card is the link, drawn. Leaving the URL in the body underneath it would say the - // same thing twice, so the span the card was built from goes with it and the prose around - // it closes up; a message that was nothing but the link leaves no body at all. - val bodyString = if (linkCard == null) { - text - } else { - text.withoutLinkSpan(linkCard.start, linkCard.end) - } - - // Nothing left to put in a bubble. The card is already a surface with its own fill and its own - // rounded shape, so a bubble behind it draws a second, slightly larger card around the first. - // A citation and the edited marker belong to the message rather than to the link, and either - // one keeps the bubble. - if (linkCard != null && bodyString.isEmpty() && quote == null && !isEdited) { - BareLinkCard( - card = linkCard, - isFromSelf = isFromSelf, - position = position, - maxWidth = maxWidth, - modifier = modifier, - attention = attention, - ) - return - } + val bodyString = text // A reply hands the bubble the narrower surround, so the citation clears the body's own inset // on both sides; the body then puts the difference back and keeps the inset it has without a @@ -408,42 +423,23 @@ private fun TextBubble( ) } - val quotedOrPlainBody = @Composable { - if (quote == null) { - bodyText() - } else { - QuotedBody( - gap = BubbleDefaults.surroundInset, - quote = { - ChatQuotePanel( - quote = quote, - onClick = onQuoteClick, - onLongClick = onQuoteLongClick, - // Tagged because the citation repeats the quoted message's own text, so a - // UI test matching on that text cannot tell the two apart. - modifier = Modifier.testTag("bubble_reply_quote"), - ) - }, - body = bodyText, - ) - } - } - - // Inside the bubble, above what is left of the body, on the same surround the citation - // uses — the card and the message it came from are one message. - if (linkCard == null) { - quotedOrPlainBody() + if (quote == null) { + bodyText() } else { - val onCardClick = rememberLinkCardClick() - Column(verticalArrangement = Arrangement.spacedBy(BubbleDefaults.surroundInset)) { - LinkCardView( - card = linkCard, - onClick = onCardClick, - ) - // A link on its own never reaches here -- it is drawn bubble-less above -- so - // what is left is a citation, an edited marker, or prose the link sat inside. - quotedOrPlainBody() - } + QuotedBody( + gap = BubbleDefaults.surroundInset, + quote = { + ChatQuotePanel( + quote = quote, + onClick = onQuoteClick, + onLongClick = onQuoteLongClick, + // Tagged because the citation repeats the quoted message's own text, so a + // UI test matching on that text cannot tell the two apart. + modifier = Modifier.testTag(REPLY_QUOTE_TAG), + ) + }, + body = bodyText, + ) } if (isEdited) { @@ -511,13 +507,16 @@ private val JUMBO_EMOJI_SIZE = 44.sp internal const val JUMBO_EMOJI_TAG = "bubble_jumbo_emoji" /** - * A card standing in for the whole message, with no bubble behind it. + * The card row of a split message, with no bubble behind it. * * Everything the bubble would have contributed is already the card's: the fill, the rounded corners * and the tap. What is not the card's is the flash a jump leaves on the message it landed on, which * belongs to the transcript rather than to the bubble -- so this goes through [Bubble] as the jumbo * emoji does, with `bare` dropping the fill and the horizontal inset and leaving the flash, the * width ceiling and the corner clip where every other bubble already gets them. + * + * A reply whose link came first carries its citation here, above the card, on the same gap the + * card keeps from its neighbours: the row has no bubble for the panel to sit inside. */ @Composable private fun BareLinkCard( @@ -526,6 +525,11 @@ private fun BareLinkCard( position: BubblePosition, maxWidth: Dp, modifier: Modifier = Modifier, + quote: ChatQuote? = null, + onQuoteClick: (() -> Unit)? = null, + onQuoteLongClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + interactive: Boolean = true, attention: () -> Float = { 0f }, ) { val onCardClick = rememberLinkCardClick() @@ -544,7 +548,24 @@ private fun BareLinkCard( verticalPadding = 0.dp, attention = attention, ) { - LinkCardView(card = card, onClick = onCardClick) + Column(verticalArrangement = Arrangement.spacedBy(BubbleDefaults.surroundInset)) { + if (quote != null) { + ChatQuotePanel( + quote = quote, + onClick = onQuoteClick, + onLongClick = onQuoteLongClick, + modifier = Modifier.testTag(REPLY_QUOTE_TAG), + ) + } + LinkCardView( + card = card, + // Dropped with the backdrop up, like every other target on the row. + onClick = if (interactive) onCardClick else null, + // The card takes the press for its own tap, so it has to hand the transcript's + // selection gesture back or a long press on it would select nothing. + onLongClick = onLongClick, + ) + } } } @@ -830,7 +851,7 @@ private data class BubbleCorners( /** * Whether [item] tucks into the same run as the bubble [other] next to it. * - * A bare emoji breaks the run on both sides. It draws no bubble, so there is no edge for its + * A bare emoji or a card row breaks the run on both sides. It draws no bubble, so there is no edge for its * neighbour to square itself against, and a squared corner facing open space reads as half a bubble * with the other half missing. Either side being bare is enough, so the message under an emoji * closes its top corners the same way the message above it closes its bottom ones. @@ -842,8 +863,8 @@ private fun groupsWith( ): Boolean = other != null && item.isSameAuthorAs(other) && config.isGrouped(item.timestamp, other.timestamp) && - !item.rendersBareEmoji() && - !other.rendersBareEmoji() + !item.rendersBare() && + !other.rendersBare() fun bubblePositionOf( index: Int, @@ -910,6 +931,34 @@ private fun previewCard( state = state, ) +/** + * [text] as the transcript would draw it: the bubble split around its card, top row first, with + * the positions the list would give each row. + */ +@Composable +private fun PreviewSplitMessage( + text: String, + card: LinkCard, + isFromSelf: Boolean = false, + isEdited: Boolean = false, +) { + val rows = ChatListItem.ContentBubble( + messageId = 1, + contentIndex = 0, + content = MessageContent.Text(text), + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochSeconds(0), + isEdited = isEdited, + linkCard = card, + ).splitAroundLinkCard() + Column( + modifier = Modifier.width(360.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + rows.forEach { row -> ContentBubble(item = row, position = BubblePosition.Solo) } + } +} + /** * What the card looks like while its lookup is out — and so what every cash link looks like for the * moment after the transcript paints, since the transcript no longer waits for the answer. @@ -920,13 +969,10 @@ private fun previewCard( @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable -private fun Preview_TextBubble_LinkCard_Loading() { - TextBubble( - text = PREVIEW_CASH_TEXT, - isFromSelf = false, - position = BubblePosition.Solo, - maxWidth = 300.dp, - linkCard = previewCard(state = LinkCard.Cash.State.Loading), +private fun Preview_LinkCard_Loading() { + PreviewSplitMessage( + text = PREVIEW_CASH_LINK, + card = previewCard(state = LinkCard.Cash.State.Loading, text = PREVIEW_CASH_LINK), ) } @@ -934,26 +980,21 @@ private fun Preview_TextBubble_LinkCard_Loading() { @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable -private fun Preview_TextBubble_LinkCard_Unresolved() { - TextBubble( - text = PREVIEW_CASH_TEXT, - isFromSelf = false, - position = BubblePosition.Solo, - maxWidth = 300.dp, - linkCard = previewCard(state = LinkCard.Cash.State.Unresolved), +private fun Preview_LinkCard_Unresolved() { + PreviewSplitMessage( + text = PREVIEW_CASH_LINK, + card = previewCard(state = LinkCard.Cash.State.Unresolved, text = PREVIEW_CASH_LINK), ) } +/** Text before the link: a text row above a bare card row. */ @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable -private fun Preview_TextBubble_LinkCard_Claimable() { - TextBubble( +private fun Preview_LinkCard_Claimable() { + PreviewSplitMessage( text = PREVIEW_CASH_TEXT, - isFromSelf = false, - position = BubblePosition.Solo, - maxWidth = 300.dp, - linkCard = previewCard( + card = previewCard( state = LinkCard.Cash.State.Resolved( amount = "$5.00", claim = LinkCard.Cash.Claim.Claimable, @@ -963,17 +1004,21 @@ private fun Preview_TextBubble_LinkCard_Claimable() { ) } +/** + * Text on both sides of the link, edited: three rows, with "Edited" inside the last bubble only and + * a second link left underlined in the trailing row. + */ @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable -private fun Preview_TextBubble_LinkCard_Claimed() { - TextBubble( - text = "sent you this $PREVIEW_CASH_LINK", +private fun Preview_LinkCard_SplitMessage() { + val text = "sent you this $PREVIEW_CASH_LINK go grab it before https://flipcash.app closes" + PreviewSplitMessage( + text = text, isFromSelf = true, - position = BubblePosition.Solo, - maxWidth = 300.dp, - linkCard = previewCard( - text = "sent you this $PREVIEW_CASH_LINK", + isEdited = true, + card = previewCard( + text = text, state = LinkCard.Cash.State.Resolved( amount = "$5.00", claim = LinkCard.Cash.Claim.Claimed, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt index b36f33b55d..41dbfd1bf3 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/ChatListItemContentTypeTest.kt @@ -50,9 +50,9 @@ class ChatListItemContentTypeTest { } @Test - fun `a carded bubble does not recycle against a plain text bubble`() { + fun `a card row does not recycle against a plain text bubble`() { val plain = bubble(MessageContent.Text("https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3")) - val carded = bubble(plain.content, linkCard = card) + val carded = bubble(plain.content, linkCard = card).splitAroundLinkCard().single() assertNotEquals(plain.itemContentType, carded.itemContentType) assertEquals("link-card-bubble", carded.itemContentType) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt new file mode 100644 index 0000000000..e41bcaa30a --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt @@ -0,0 +1,122 @@ +package com.flipcash.shared.chat.models + +import com.flipcash.services.models.chat.MessageContent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * A message holding a carded link draws as up to three rows: the text before the link, the card, + * the text after it. These pin the order, what is dropped, and the keys the list tells them apart by. + */ +class SplitAroundLinkCardTest { + + private val link = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + + private fun split(text: String, content: MessageContent = MessageContent.Text(text)) = + ChatListItem.ContentBubble( + messageId = 42, + contentIndex = 0, + content = content, + isFromSelf = true, + timestamp = Instant.fromEpochSeconds(1_000), + isEdited = true, + linkCard = LinkCard.Cash( + url = link, + start = text.indexOf(link), + end = text.indexOf(link) + link.length, + entropy = "KNi8pQr1n5hRU65vKJGge3", + state = LinkCard.Cash.State.Unresolved, + ), + ).splitAroundLinkCard() + + @Test + fun `text on both sides splits into three rows in source order`() { + val rows = split("before $link after") + + assertEquals( + listOf(MessagePart.Leading, MessagePart.Card, MessagePart.Trailing), + rows.map { it.part }, + ) + assertEquals(listOf("before", null, "after"), rows.map { it.partText }) + assertEquals(listOf(true, false, false), rows.map { it.isFirstRow }) + assertEquals(listOf(false, false, true), rows.map { it.isLastRow }) + } + + @Test + fun `only the card row carries the card`() { + val rows = split("before $link after") + + assertEquals(listOf(false, true, false), rows.map { it.linkCard != null }) + } + + @Test + fun `every row keeps the whole message for copy and edit`() { + val text = "before $link after" + val rows = split(text) + + assertEquals(listOf(text, text, text), rows.map { it.plainText }) + assertEquals(1, rows.map { it.messageKey }.distinct().size) + } + + @Test + fun `row ids are the message id plus the part`() { + val rows = split("before $link after") + + assertEquals( + listOf("42-0#leading", "42-0#card", "42-0#trailing"), + rows.map { it.itemKey }, + ) + } + + @Test + fun `a link alone is one card row`() { + val row = split(link).single() + + assertEquals(MessagePart.Card, row.part) + assertEquals(true, row.isFirstRow) + assertEquals(true, row.isLastRow) + } + + @Test + fun `whitespace-only segments are dropped`() { + assertEquals( + listOf(MessagePart.Leading, MessagePart.Card), + split("look $link \n ").map { it.part }, + ) + assertEquals( + listOf(MessagePart.Card, MessagePart.Trailing), + split(" \n$link thanks").map { it.part }, + ) + } + + @Test + fun `other links stay in the text row they fell in`() { + val rows = split("$link and https://flipcash.app") + + assertEquals("and https://flipcash.app", rows.last().partText) + } + + @Test + fun `a reply splits on its text`() { + val text = "see $link" + val rows = split(text, MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text(text)))) + + assertEquals(listOf(MessagePart.Leading, MessagePart.Card), rows.map { it.part }) + } + + @Test + fun `a bubble with no card is one row`() { + val row = ChatListItem.ContentBubble( + messageId = 1, + contentIndex = 0, + content = MessageContent.Text("hi"), + isFromSelf = true, + timestamp = Instant.fromEpochSeconds(0), + ).splitAroundLinkCard().single() + + assertNull(row.part) + assertEquals("1-0", row.itemKey) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt index 9ed366c13d..c8a8d49d25 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/BubblePositionTest.kt @@ -2,8 +2,10 @@ package com.flipcash.shared.chat.ui import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig +import com.flipcash.shared.chat.models.splitAroundLinkCard import org.junit.Test import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds @@ -127,4 +129,34 @@ class BubblePositionTest { positionsOf(items), ) } + + @Test + fun `a card row breaks the run around it, as a bare emoji does`() { + val link = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + val text = "before $link after" + val carded = bubble(2, secondsIn = 10, senderId = alice.userId, text = text).copy( + linkCard = LinkCard.Cash( + url = link, + start = text.indexOf(link), + end = text.indexOf(link) + link.length, + entropy = "KNi8pQr1n5hRU65vKJGge3", + state = LinkCard.Cash.State.Unresolved, + ), + ) + // Newest-first, as the view model emits a split message: its bottom row first. + val items = listOf(bubble(3, secondsIn = 20, senderId = alice.userId)) + + carded.splitAroundLinkCard().asReversed() + + bubble(1, secondsIn = 0, senderId = alice.userId) + + assertEquals( + listOf( + BubblePosition.Last, // newest, grouped under the trailing row + BubblePosition.First, // trailing, its run closed above by the card + BubblePosition.Solo, // card, in no run + BubblePosition.Last, // leading, its run closed below by the card + BubblePosition.First, // oldest, grouped over the leading row + ), + positionsOf(items), + ) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkSpanRemovalTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkSpanRemovalTest.kt deleted file mode 100644 index 0a6aa85783..0000000000 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/LinkSpanRemovalTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.flipcash.shared.chat.ui - -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * A link that renders as a card is removed from the body text, so the card and the URL do not say - * the same thing twice. The spans come from the real detector rather than hand-counted offsets — - * the bubble cuts what the detector found, and a test that cut something else would pass while the - * transcript left half a URL behind. - */ -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE) -class LinkSpanRemovalTest { - - private fun strip(text: String): String { - val link = detectUrls(text).first() - return text.withoutLinkSpan(link.start, link.end) - } - - @Test - fun `a message that is only a link leaves no text`() { - assertEquals("", strip("https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3")) - } - - @Test - fun `text before the link keeps its punctuation and loses the trailing space`() { - assertEquals( - "here you go:", - strip("here you go: https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3"), - ) - } - - @Test - fun `a link mid-sentence does not leave a double space behind`() { - assertEquals("check out", strip("check https://flipcash.com out")) - } - - @Test - fun `a link on its own line takes the line with it`() { - assertEquals( - "here you go\nenjoy", - strip("here you go\nhttps://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3\nenjoy"), - ) - } - - @Test - fun `a span the text cannot hold is left alone rather than thrown on`() { - val text = "here you go" - assertEquals(text, text.withoutLinkSpan(start = 4, end = text.length + 10)) - assertEquals(text, text.withoutLinkSpan(start = -1, end = 3)) - assertEquals(text, text.withoutLinkSpan(start = 6, end = 2)) - } -} From 54b7741333cf05e1a35f5fd1e738fcba3c0ef30f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 20:37:40 -0400 Subject: [PATCH 2/3] feat(chat): card a group's invite link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link to `app.flipcash.com/chat/{uuid}` stayed plain text. It now gets a card, as on iOS (code-ios-app#830). `/chat/{uuid}/send` and the tip chat route are still refused. The card reads the group with GetChat in the redacted view, which answers for non-members, and uses only its title, picture, roster summary and rules. `members` is never read, so no member name or avatar can reach the card. An untitled group is named "Group Chat" for now, pending copy. Layout follows the group header card (nodes 10125:19157, 10127:118280, 10127:116723): radius 12, a white 10% stroke, a 46dp band across the top tinted at 28% opacity, and a 64dp ringed avatar centred on the band's bottom edge. Under it are the title, the member count and the rule, as `ChatInfoCard` renders them, then a 44dp button at least 16dp below. The band's colour is the picture's blurhash DC component, falling back to the avatar placeholder gradient's first stop. `BlurHash.averageColor` is checked against the shared `blurhash_average.json` vectors. The card is as wide as the cash card, and its height is the larger of its content and width × 224/328, measured intrinsically at the card's own width. So a wrapped requirement line or a large font scale grows the row instead of overlapping its neighbours. Only the button takes a tap. "Start Chatting" pushes the group's conversation onto the root stack, so Back returns to the chat holding the link, and the pushed screen handles joining or buying. A card linking to the chat on screen does nothing. An unavailable group shows only the band and a disabled "Group Unavailable" button. --- .../core/src/main/res/values/strings.xml | 4 + .../internal/link/GroupLinkLookup.kt | 64 ++++ .../internal/link/LinkCardClassifier.kt | 13 + .../messenger/internal/link/LinkCardModule.kt | 2 + .../internal/link/LinkCardResolver.kt | 17 +- .../internal/screens/MessengerScreen.kt | 16 + .../internal/link/LinkCardClassifierTest.kt | 35 ++ .../internal/link/LinkCardResolverTest.kt | 63 ++++ .../flipcash/shared/chat/models/ChatAction.kt | 7 + .../flipcash/shared/chat/models/LinkCard.kt | 52 +++ .../shared/chat/ui/GroupInviteLinkCard.kt | 353 ++++++++++++++++++ .../flipcash/shared/chat/ui/LinkCardView.kt | 25 ++ .../flipcash/shared/chat/ui/MessageBubble.kt | 34 ++ .../com/flipcash/shared/common/ui/BlurHash.kt | 20 + .../ui/BlurHashAverageColorVectorTest.kt | 54 +++ .../src/test/resources/blurhash_average.json | 89 +++++ 16 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/GroupLinkLookup.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt create mode 100644 apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/BlurHashAverageColorVectorTest.kt create mode 100644 apps/flipcash/shared/common-ui/src/test/resources/blurhash_average.json diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 9cdc4b221d..cf56065d47 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -965,6 +965,10 @@ Tap to claim Claimed Expired + + Group Chat + Start Chatting + Group Unavailable Copy diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/GroupLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/GroupLinkLookup.kt new file mode 100644 index 0000000000..8bac7b39f0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/GroupLinkLookup.kt @@ -0,0 +1,64 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.app.core.tokens.brandedName +import com.flipcash.app.core.tokens.isReserve +import com.flipcash.app.messenger.internal.balanceRequirement +import com.flipcash.app.messenger.internal.requiresStaff +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ViewMode +import com.flipcash.shared.chat.models.LinkCard +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.ResourceHelper +import javax.inject.Inject + +/** + * Reads a group's public record for an invite card. + * + * `GetChat` in the redacted view answers for any registered user, member or not, which is what an + * invite needs: most people reading one are not in the group yet. The card is built from four + * fields and no others -- title, picture, roster summary, rules. [ChatMetadata.members] is never + * read here, so no member name or avatar can reach the card, including as a fallback title. + * + * Anything that is not a group -- a DM id pasted into a `/chat/` link, or a chat that has gone -- + * fails the lookup, and the card draws its unavailable state. + * + * The requirement is stated the way the chat's own head card states it, including the reserve + * being named by the amount alone ("$100", not "$100 of Dollars"). An unknown mint is not a + * failure: the amount is still true, so it is stated without a token beside it. + */ +internal class GroupLinkLookup @Inject constructor( + private val chatController: ChatController, + private val tokenCoordinator: TokenCoordinator, + private val resources: ResourceHelper, +) { + suspend operator fun invoke(chatId: ChatId): Result = + runCatching { + val chat = chatController.getChat(chatId, ViewMode.REDACTED).getOrThrow() + require(chat.type == ChatType.GROUP) { "chat $chatId is not a group" } + + val balance = chat.rules.balanceRequirement() + val staffOnly = chat.rules.requiresStaff() + val requirement = if (balance != null || staffOnly) { + val token = balance?.mints?.firstOrNull()?.let { mint -> + tokenCoordinator.getTokenMetadata(Mint(mint.bytes)).getOrNull()?.token + } + LinkCard.GroupInvite.Requirement( + amount = balance?.amount?.formatted(), + currencyName = token?.takeUnless { it.isReserve }?.brandedName(resources), + staffOnly = staffOnly, + ) + } else { + null + } + + LinkCard.GroupInvite.State.Resolved( + title = chat.title?.takeIf { it.isNotBlank() }, + picture = chat.picture, + memberCount = chat.rosterSummary.memberCount, + requirement = requirement, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index fbde976c25..cc877d2e50 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -71,6 +71,16 @@ internal class LinkCardClassifier @Inject constructor( mint = type.mint, state = LinkCard.TokenInfo.State.Loading, ) + // A group's invite, and only the bare `/chat/{uuid}`. The router claims anything under + // it, so the send-cash path beneath (`/chat/{uuid}/send`, iOS's `chatSendCash`) is + // refused here by shape: it opens a payment, not the group. `TipChat` falls to `else`. + is DeeplinkType.GroupChatInvite -> LinkCard.GroupInvite( + url = target, + start = link.start, + end = link.end, + chatId = type.chatId, + state = LinkCard.GroupInvite.State.Loading, + ).takeIf { target.toUri().pathSegments.size == GROUP_INVITE_SEGMENTS } else -> null } } @@ -105,6 +115,9 @@ internal class LinkCardClassifier @Inject constructor( } companion object { + /** `chat`, then the id. */ + private const val GROUP_INVITE_SEGMENTS = 2 + private const val JUMP_HOST = "jump.flipcash.com" private const val JUMP_SOURCE_PARAM = "source=" diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt index 3de9fb20ff..7b6f46910f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt @@ -29,6 +29,7 @@ internal object LinkCardModule { fun provideLinkCardResolver( giftCard: GiftCardLookup, token: TokenLinkLookup, + group: GroupLinkLookup, ): LinkCardResolver = LinkCardResolver( // The resolver's own scope, ended by `ChatViewModel.onCleared`. A query outlives the @@ -37,5 +38,6 @@ internal object LinkCardModule { scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), giftCard = { giftCard(it) }, tokenMetadata = { token(it) }, + group = { group(it) }, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index 6d6f95e80a..dc804b720d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.link import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint import kotlinx.coroutines.CoroutineScope @@ -25,7 +26,8 @@ import java.util.concurrent.ConcurrentHashMap * hard stop. Nothing on this path may reach `BillController.receiveGiftCard`, because that claims * the link, and a card that claimed what it rendered would empty a link by scrolling past it. * - * A token link needs only the mint's metadata, which the wallet already caches. + * A token link needs only the mint's metadata, which the wallet already caches. A group invite + * needs the group's public record, read in the redacted view -- see [GroupLinkLookup]. * * The query runs in [scope] rather than in the caller's coroutine. The caller is a card that has * just been composed, and it is cancelled by an ordinary scroll. A query awaited inline dies with @@ -42,6 +44,7 @@ internal class LinkCardResolver( private val scope: CoroutineScope, private val giftCard: suspend (entropy: String) -> Result, private val tokenMetadata: suspend (mint: Mint) -> Result, + private val group: suspend (chatId: ChatId) -> Result, ) : LinkCardResolution { data class Snapshot( @@ -59,6 +62,7 @@ internal class LinkCardResolver( */ private val cashQueries = mutableMapOf>() private val tokenQueries = mutableMapOf>() + private val groupQueries = mutableMapOf>() /** * The answers that have landed, readable without the lock and without suspending — which is the @@ -75,6 +79,7 @@ internal class LinkCardResolver( */ private val cashAnswers = ConcurrentHashMap() private val tokenAnswers = ConcurrentHashMap() + private val groupAnswers = ConcurrentHashMap() private val _revision = MutableStateFlow(0) override val revision: StateFlow = _revision.asStateFlow() @@ -82,11 +87,13 @@ internal class LinkCardResolver( override fun peek(card: LinkCard): LinkCard? = when (card) { is LinkCard.Cash -> cashAnswers[card.entropy]?.let { card.copy(state = it) } is LinkCard.TokenInfo -> tokenAnswers[card.mint]?.let { card.copy(state = it) } + is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) } } override suspend fun resolve(card: LinkCard): LinkCard = when (card) { is LinkCard.Cash -> card.copy(state = cashState(card.entropy)) is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint)) + is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId)) } /** @@ -163,6 +170,14 @@ internal class LinkCardResolver( ) } + private suspend fun groupState(chatId: ChatId): LinkCard.GroupInvite.State = + memoized(groupQueries, chatId) { + group(chatId).fold( + onSuccess = { resolved -> resolved.also { groupAnswers[chatId] = it } }, + onFailure = { forget(groupQueries, chatId); LinkCard.GroupInvite.State.Unavailable }, + ) + } + private suspend fun memoized( queries: MutableMap>, key: K, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 2af58ac0d3..7163403c5f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel @@ -78,6 +79,21 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + is ChatAction.OpenGroup -> { + // An invite to the chat already on screen has nowhere to go. + if (action.chatId != state.chatId) { + keyboard.hideIfVisible { + viewModel.dispatchEvent( + ChatViewModel.Event.OpenScreen( + // Pushed, so Back returns to this chat. The pushed screen gates + // itself: a non-member sees the join or the buy there. + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(action.chatId)) + ) + ) + } + } + } + is ChatAction.ToggleSelection -> { viewModel.dispatchEvent( ChatViewModel.Event.ToggleMessageSelection(action.bubble) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index d99d8e814b..1d8ad9634d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -4,12 +4,15 @@ import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.Router import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.ui.DetectedUrl +import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.model.core.bytes import com.getcode.solana.keys.Mint import dev.theolm.rinku.DeepLink import org.json.JSONObject import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -38,6 +41,10 @@ class LinkCardClassifierTest { "c", "cash" -> DeeplinkType.CashLink(link.entropyFragmentForTest().orEmpty()) "token" -> link.pathSegmentsForTest().getOrNull(1) ?.let { DeeplinkType.TokenInfo(Mint(it)) } + // As `AppRouter` does: the id segment is checked, anything after it is not. + "chat" -> link.pathSegmentsForTest().getOrNull(1) + ?.let { runCatching { UUID.fromString(it) }.getOrNull() } + ?.let { DeeplinkType.GroupChatInvite(ChatId(it.bytes)) } else -> null } } @@ -144,6 +151,34 @@ class LinkCardClassifierTest { assertNull(classifier.firstCard(listOf(DetectedUrl(0, hostile.length, hostile)))) } + /** Not in the canonical fixture yet; iOS holds the same case in its own classifier tests. */ + @Test + fun `a group invite link becomes a group card`() { + val classifier = LinkCardClassifier(router) + val url = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162" + + val card = classifier.firstCard(listOf(DetectedUrl(0, url.length, url))) + + val group = card as? LinkCard.GroupInvite + assertEquals( + ChatId(UUID.fromString("6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162").bytes), + group?.chatId, + ) + assertEquals(url, group?.url) + assertEquals(0, group?.start) + assertEquals(url.length, group?.end) + assertEquals(LinkCard.GroupInvite.State.Loading, group?.state) + } + + /** The send-cash path under a chat opens a payment, not the group, so it stays a plain link. */ + @Test + fun `a chat send cash link stays a link`() { + val classifier = LinkCardClassifier(router) + val url = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162/send" + + assertNull(classifier.firstCard(listOf(DetectedUrl(0, url.length, url)))) + } + /** A jump wrapper is unwrapped once. One pointing at another jump is malformed, not a card. */ @Test fun `a jump wrapper pointing at another jump is not a card`() { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index ca4fa86926..1ba86722de 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.link +import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.models.LinkCard import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint @@ -45,6 +46,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.failure(IllegalStateException("offline")) }, tokenMetadata = { Result.failure(IllegalStateException("offline")) }, + group = { Result.failure(IllegalStateException("unused")) }, ) assertEquals(LinkCard.Cash.State.Unresolved, (resolver.resolve(card) as LinkCard.Cash).state) } @@ -55,6 +57,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) val state = (resolver.resolve(card) as LinkCard.Cash).state assertTrue(state is LinkCard.Cash.State.Resolved) @@ -71,6 +74,7 @@ class LinkCardResolverTest { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) resolver.resolve(card) @@ -87,6 +91,7 @@ class LinkCardResolverTest { Result.failure(IllegalStateException("offline")) }, tokenMetadata = { Result.failure(IllegalStateException("offline")) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) resolver.resolve(card) @@ -101,6 +106,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.failure(IllegalStateException("not asked")) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) val resolved = resolver.resolve(tokenCard) as LinkCard.TokenInfo assertTrue(resolved.state is LinkCard.TokenInfo.State.Resolved) @@ -112,6 +118,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.failure(IllegalStateException("not asked")) }, tokenMetadata = { Result.failure(IllegalStateException("no metadata")) }, + group = { Result.failure(IllegalStateException("unused")) }, ) val resolved = resolver.resolve(tokenCard) as LinkCard.TokenInfo assertEquals(LinkCard.TokenInfo.State.Unresolved, resolved.state) @@ -135,6 +142,7 @@ class LinkCardResolverTest { ) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) val before = (resolver.resolve(card) as LinkCard.Cash).state assertEquals(LinkCard.Cash.Claim.Claimable, (before as LinkCard.Cash.State.Resolved).claim) @@ -153,6 +161,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { calls++; Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) val other = card.copy(entropy = "8mXeQ2vTb4pLzRw9dKcHfA") resolver.resolve(card) @@ -179,6 +188,7 @@ class LinkCardResolverTest { Result.success(snapshot().copy(claim = claims.getValue(entropy))) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) val claimed = card.copy(entropy = "8mXeQ2vTb4pLzRw9dKcHfA") resolver.resolve(card) @@ -206,6 +216,7 @@ class LinkCardResolverTest { Result.success(snapshot().copy(claim = LinkCard.Cash.Claim.Expired)) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) @@ -221,6 +232,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.failure(IllegalStateException("not asked")) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(tokenCard) @@ -237,6 +249,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { cashCalls++; Result.success(snapshot()) }, tokenMetadata = { tokenCalls++; Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) resolver.resolve(tokenCard) @@ -250,6 +263,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) assertNull(resolver.peek(card), "nothing has been asked yet") @@ -268,6 +282,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.failure(IllegalStateException("offline")) }, tokenMetadata = { Result.failure(IllegalStateException("offline")) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) @@ -282,6 +297,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) val before = resolver.revision.value @@ -298,6 +314,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.success(snapshot().copy(claim = LinkCard.Cash.Claim.Claimed)) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) val before = resolver.revision.value @@ -315,6 +332,7 @@ class LinkCardResolverTest { scope = backgroundScope, giftCard = { Result.success(snapshot()) }, tokenMetadata = { Result.success(mock()) }, + group = { Result.failure(IllegalStateException("unused")) }, ) resolver.resolve(card) val before = resolver.revision.value @@ -322,4 +340,49 @@ class LinkCardResolverTest { assertTrue(resolver.refreshClaimable()) assertTrue(resolver.revision.value > before) } + + private val groupCard = LinkCard.GroupInvite( + url = "https://app.flipcash.com/chat/0f8e2a4c-1b3d-4e5f-8a9b-0c1d2e3f4a5b", + start = 0, + end = 66, + chatId = ChatId(ByteArray(16) { it.toByte() }), + state = LinkCard.GroupInvite.State.Loading, + ) + + @Test + fun `a failed group lookup draws the unavailable card`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("not a group")) }, + ) + assertEquals( + LinkCard.GroupInvite.State.Unavailable, + (resolver.resolve(groupCard) as LinkCard.GroupInvite).state, + ) + // Not remembered: the next appearance asks again. + assertNull(resolver.peek(groupCard)) + } + + @Test + fun `a resolved group is held for the visit`() = runTest { + var calls = 0 + val resolved = LinkCard.GroupInvite.State.Resolved( + title = "Bad Boys", + picture = null, + memberCount = 412, + requirement = null, + ) + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { calls++; Result.success(resolved) }, + ) + assertEquals(resolved, (resolver.resolve(groupCard) as LinkCard.GroupInvite).state) + assertEquals(resolved, (resolver.peek(groupCard) as LinkCard.GroupInvite).state) + resolver.resolve(groupCard.copy(chatId = ChatId(ByteArray(16) { it.toByte() }))) + assertEquals(1, calls) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index f2333c64f9..9492f5fa82 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -1,6 +1,7 @@ package com.flipcash.shared.chat.models import androidx.compose.runtime.staticCompositionLocalOf +import com.flipcash.services.models.chat.ChatId import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Mint @@ -9,6 +10,12 @@ sealed interface ChatAction { data class AdvanceReadPointer(val messageId: Long) : ChatAction object RefreshContact : ChatAction data class ViewToken(val mint: Mint) : ChatAction + + /** + * Opens the group a link card names, pushed over this chat so Back returns here. The pushed + * screen gates itself; a card never joins or buys on the reader's behalf. + */ + data class OpenGroup(val chatId: ChatId) : ChatAction data object ViewProfile : ChatAction /** diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt index 6318d10a63..a92ab948ed 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt @@ -1,5 +1,7 @@ package com.flipcash.shared.chat.models +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MediaItem import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint @@ -107,4 +109,54 @@ sealed interface LinkCard { ) : State } } + + /** + * A group chat's invite link, `app.flipcash.com/chat/{uuid}`. + * + * Built from the chat's public record only — title, picture, member count, rules — and never + * from its roster, which is private to members whatever the group's mode. So there are no + * member avatars on the card and no member names, including as a fallback title. + */ + data class GroupInvite( + override val url: String, + override val start: Int, + override val end: Int, + val chatId: ChatId, + val state: State, + ) : LinkCard { + + sealed interface State { + /** The lookup is out. Drawn as a shimmer at the card's minimum height. */ + data object Loading : State + + /** + * No group to show: the lookup failed, or the chat is gone or is not a group. Not + * remembered, so the next appearance asks again. + */ + data object Unavailable : State + + data class Resolved( + /** The group's own title, or null when it has none; the card names it then. */ + val title: String?, + /** The chat's picture, read through the chat's own access context. */ + val picture: MediaItem?, + /** From the roster summary, never from counting members. */ + val memberCount: Long, + /** The entry rule as the chat's head card states it, or null when it has none. */ + val requirement: Requirement?, + ) : State + } + + /** + * What the chat's head card says about its rules, already formatted: the card states the + * rule without judging the viewer against it. + */ + data class Requirement( + /** Pre-formatted, e.g. "$100"; null when the rules name no balance. */ + val amount: String?, + /** The token to name beside [amount], or null to state the amount alone. */ + val currencyName: String?, + val staffOnly: Boolean, + ) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt new file mode 100644 index 0000000000..4808e414fd --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt @@ -0,0 +1,353 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.ui.shimmer +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.BlobId +import com.flipcash.services.models.chat.BlobMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ImageMetadata +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.common.ui.BlurHash +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +/** + * A group invite, drawn from the group's header card at the top of its own transcript (node + * 10125:19157): the same avatar, title, count and rule, in the same order, so the card previews the + * screen it opens. Nodes 10127:118280 and 10127:116723 are the card itself. + * + * Only the button takes a tap. The card is built from the group's public record, and a tap anywhere + * on it opening the group would be indistinguishable from a tap meant for the message around it; + * the button says what happens. [onStart] is null with the backdrop up, which leaves it inert. + * + * [minHeight] is the voucher's proportions at this width, as a floor rather than a size: the column + * grows past it with the font scale instead of clipping. Measured at the card's own width, which in + * Compose is simply the width it is laid out at -- the requirement line wraps there and the row + * grows with it. + * + * Nothing from the roster reaches here, so there are no member avatars and no member names, even as + * a fallback title; an untitled group is named generically. + */ +@Composable +internal fun GroupInviteLinkCard( + card: LinkCard.GroupInvite, + minHeight: Dp, + onStart: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val shape = GroupInviteCardDefaults.SHAPE + val state = card.state + + if (state is LinkCard.GroupInvite.State.Loading) { + // Nothing about a group is known before the lookup -- not even whether it is one -- so the + // loading card is a plain shimmer at the floor, and the row grows when the content lands. + Box( + modifier = modifier + .fillMaxWidth() + .height(minHeight) + .clip(shape) + .background(CodeTheme.colors.surfaceVariant) + .shimmer(shape), + ) + return + } + + val resolved = state as? LinkCard.GroupInvite.State.Resolved + val placeholderTop = CodeTheme.colors.contactAvatar.colors.first() + val tint = remember(resolved?.picture, placeholderTop) { + BlurHash.averageColor(resolved?.picture?.blurhash()) + ?.let { Color(0xFF000000.toInt() or it) } + ?: placeholderTop + } + + val title = resolved?.title ?: stringResource(R.string.label_linkCard_untitledGroup) + + Box( + modifier = modifier + .fillMaxWidth() + // The content's height at this width, or the floor if that is taller. Intrinsic, so the + // spacer's weight below only spends what the floor leaves over and never grows the card + // to whatever height its parent happens to offer. + .heightIn(min = minHeight) + .height(IntrinsicSize.Min) + .clip(shape) + .border( + width = CodeTheme.dimens.border, + color = GroupInviteCardDefaults.STROKE, + shape = shape, + ), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(GroupInviteCardDefaults.BAND_HEIGHT) + .background(tint.copy(alpha = GroupInviteCardDefaults.BAND_OPACITY)), + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + // The avatar's centre sits on the band's bottom edge. + top = GroupInviteCardDefaults.BAND_HEIGHT - GroupInviteCardDefaults.AVATAR / 2, + bottom = CodeTheme.dimens.inset, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (resolved != null) { + ContactAvatar( + image = resolved.picture, + displayName = title, + access = BlobAccessContext.ChatProfile(card.chatId), + // Size first: the card is measured intrinsically, and a fixed size answers that + // without asking the avatar's BoxWithConstraints, which would throw. + modifier = Modifier + .size(GroupInviteCardDefaults.AVATAR) + .border(CodeTheme.dimens.border, GroupInviteCardDefaults.STROKE, CircleShape) + .clip(CircleShape), + ) + + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = title, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + modifier = Modifier.padding(top = GroupInviteCardDefaults.MEMBER_COUNT_GAP), + text = pluralStringResource( + R.plurals.subtitle_chatMemberCount, + resolved.memberCount.toInt(), + resolved.memberCount.toString(), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + resolved.requirement?.let { RequirementLines(it) } + } + // Unavailable draws nothing between the band and the button: there is no group to name. + + // The least gap, then whatever the floor leaves over, so the button sits at the bottom + // of a card taller than its content. Two spacers because a weight's height is exact: a + // minimum on the weighted one would be clamped to nothing when the content is taller. + Spacer(Modifier.height(GroupInviteCardDefaults.BUTTON_GAP)) + Spacer(Modifier.weight(1f)) + + if (resolved != null) { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .height(GroupInviteCardDefaults.BUTTON_HEIGHT), + text = stringResource(R.string.action_linkCard_startChatting), + buttonState = ButtonState.Filled, + overrideContentPadding = true, + contentPadding = GroupInviteCardDefaults.NO_PADDING, + enabled = onStart != null, + onClick = { onStart?.invoke() }, + ) + } else { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .height(GroupInviteCardDefaults.BUTTON_HEIGHT), + text = stringResource(R.string.label_linkCard_groupUnavailable), + buttonState = ButtonState.Filled20, + overrideContentPadding = true, + contentPadding = GroupInviteCardDefaults.NO_PADDING, + enabled = false, + onClick = {}, + ) + } + } + } +} + +/** + * The rule as the chat's header card states it (`ChatInfoCard`): the balance line, then the staff + * line, each on its own line and centred, since both can be set on one chat. + */ +@Composable +private fun RequirementLines(requirement: LinkCard.GroupInvite.Requirement) { + requirement.amount?.let { amount -> + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = if (requirement.currencyName != null) { + stringResource(R.string.label_chat_balanceRequirement, amount, requirement.currencyName) + } else { + stringResource(R.string.label_chat_balanceRequirement_anyToken, amount) + }, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + if (requirement.staffOnly) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + text = stringResource(R.string.label_chat_staffRequirement), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +internal object GroupInviteCardDefaults { + /** The group header card's radius. */ + val SHAPE = RoundedCornerShape(12.dp) + + /** The group header card's stroke: white at 10%. Also the avatar's ring. */ + val STROKE = Color.White.copy(alpha = 0.1f) + + val BAND_HEIGHT = 46.dp + + /** Provisional, pending design sign-off. */ + const val BAND_OPACITY = 0.28f + + val AVATAR = 64.dp + + /** Title to member count. */ + val MEMBER_COUNT_GAP = 2.dp + + /** The least gap between the text and the button. */ + val BUTTON_GAP = 16.dp + + /** iOS's compact button height. Android has no compact style, so the standard one is sized. */ + val BUTTON_HEIGHT = 44.dp + + val NO_PADDING = PaddingValues(0.dp) +} + +// region Previews + +private const val PREVIEW_URL = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162" + +/** A rendition carrying only a blurhash: enough for the tint and the avatar's placeholder. */ +private fun previewPicture(blurhash: String) = MediaItem( + renditions = listOf( + MediaItemRendition( + role = MediaItemRendition.Role.THUMBNAIL, + blobId = BlobId(ByteArray(16)), + blob = BlobMetadata( + mimeType = "image/jpeg", + sizeBytes = 0, + downloadUrl = "", + image = ImageMetadata(width = 64, height = 64, blurhash = blurhash), + ), + ), + ), +) + +internal fun previewGroupCard(state: LinkCard.GroupInvite.State) = LinkCard.GroupInvite( + url = PREVIEW_URL, + start = 0, + end = PREVIEW_URL.length, + chatId = ChatId(ByteArray(16) { it.toByte() }), + state = state, +) + +internal val PreviewGroupResolved = LinkCard.GroupInvite.State.Resolved( + title = "Bad Boys", + picture = previewPicture("LEHV6nWB2yk8pyo0adR*.7kCMdnj"), + memberCount = 412, + requirement = LinkCard.GroupInvite.Requirement( + amount = "$100", + currencyName = "Bad Boys", + staffOnly = false, + ), +) + +@Composable +private fun PreviewCard(state: LinkCard.GroupInvite.State) { + Box(Modifier.width(280.dp).padding(8.dp)) { + GroupInviteLinkCard( + card = previewGroupCard(state), + minHeight = 280.dp * 224f / 328f, + onStart = {}, + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_GroupInvite_Resolved() { + PreviewCard(PreviewGroupResolved) +} + +/** No picture: the avatar's initials on its gradient, and the band tinted from that gradient. */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_GroupInvite_NoPicture() { + PreviewCard(PreviewGroupResolved.copy(picture = null, title = null, requirement = null)) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_GroupInvite_Unavailable() { + PreviewCard(LinkCard.GroupInvite.State.Unavailable) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_GroupInvite_Loading() { + PreviewCard(LinkCard.GroupInvite.State.Loading) +} + +/** Twice the font scale: the card grows past its floor rather than clipping. */ +@Preview(fontScale = 2f) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_GroupInvite_LargeFont() { + PreviewCard( + PreviewGroupResolved.copy( + requirement = PreviewGroupResolved.requirement?.copy(staffOnly = true), + ), + ) +} + +// endregion diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 92f7b0fb9b..24a52485d0 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -3,6 +3,7 @@ package com.flipcash.shared.chat.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -91,6 +93,27 @@ internal fun LinkCardView( val live = rememberResolvedCard(card) val shape = CodeTheme.shapes.medium + if (live is LinkCard.GroupInvite) { + // Only the button opens a group, so the card takes no tap of its own. The long press + // still has to reach the transcript, or pressing the card would select nothing. + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .addIf(onLongClick != null) { + Modifier.pointerInput(onLongClick) { + detectTapGestures(onLongPress = { onLongClick?.invoke() }) + } + }, + ) { + GroupInviteLinkCard( + card = live, + minHeight = maxWidth * LinkCardDefaults.CARD_ASPECT, + onStart = onClick?.let { click -> { click(live) } }, + ) + } + return + } + // The voucher's proportions, not its size: a bubble is a good deal narrower than the wallet's // card, and scaling the height with the width is what keeps it a card in chat instead of a // tall panel. @@ -112,6 +135,8 @@ internal fun LinkCardView( when (live) { is LinkCard.Cash -> CashLinkCard(card = live, height = height) is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height) + // Drawn above; unreachable here. + is LinkCard.GroupInvite -> Unit } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index a573829fea..a0ab92f788 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 @@ -580,6 +580,7 @@ private fun BareLinkCard( * app, wrong from inside a chat, where it swaps the transcript for the wallet on the way to a * screen the reader asked for directly. There is no wallet card here for the detail to grow out of. * So it pushes, exactly as the cash bubble's own token tap does, and back returns to the message. + * A group invite pushes the group's conversation for the same reason. */ @Composable private fun rememberLinkCardClick(): (LinkCard) -> Unit { @@ -595,6 +596,9 @@ private fun rememberLinkCardClick(): (LinkCard) -> Unit { uriHandler.openUri(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. + is LinkCard.GroupInvite -> actionHandler(ChatAction.OpenGroup(card.chatId)) } } } @@ -1028,6 +1032,36 @@ private fun Preview_LinkCard_SplitMessage() { ) } +/** + * A group invite split around its card, above a cash link's card: the two cards at one width, the + * group's at least the cash card's height, and only the group's button taking a tap. + */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_LinkCard_GroupInviteBesideCash() { + val group = previewGroupCard(PreviewGroupResolved) + val text = "come hang ${group.url} we're planning friday" + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PreviewSplitMessage( + text = text, + card = group.copy(start = text.indexOf(group.url), end = text.indexOf(group.url) + group.url.length), + ) + PreviewSplitMessage( + text = PREVIEW_CASH_LINK, + isFromSelf = true, + card = previewCard( + text = PREVIEW_CASH_LINK, + state = LinkCard.Cash.State.Resolved( + amount = "$5.00", + claim = LinkCard.Cash.Claim.Claimable, + token = Token.usdf, + ), + ), + ) + } +} + @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/BlurHash.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/BlurHash.kt index f601261827..30d9cdd11f 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/BlurHash.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/BlurHash.kt @@ -47,6 +47,26 @@ object BlurHash { return composeBitmap(width, height, numCompX, numCompY, colors) } + /** + * The image's average colour as `0xRRGGBB`, read from the hash's DC component without decoding + * the image, or null when [blurHash] is malformed by the same rules [decode] applies — and, as + * iOS does, when any character of it is outside the alphabet. + * + * The DC bytes are sRGB as stored; nothing is linearised, so this is the colour a designer + * would pick out of the preview. Cross-platform: `test-vectors/blurhash_average.json`. + */ + fun averageColor(blurHash: String?): Int? { + if (blurHash == null || blurHash.length < 6) return null + if (blurHash.any { it !in CHARS }) return null + + val sizeFlag = decode83(blurHash, 0, 1) ?: return null + val numCompX = sizeFlag % 9 + 1 + val numCompY = sizeFlag / 9 + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) return null + + return decode83(blurHash, 2, 6) + } + private fun decode83(str: String, from: Int, to: Int): Int? { var result = 0 for (i in from until to) { diff --git a/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/BlurHashAverageColorVectorTest.kt b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/BlurHashAverageColorVectorTest.kt new file mode 100644 index 0000000000..232236d6b2 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/BlurHashAverageColorVectorTest.kt @@ -0,0 +1,54 @@ +package com.flipcash.shared.common.ui + +import org.json.JSONObject +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `test-vectors/blurhash_average.json`. The canonical copy lives in the orchestrator repo; this one + * is synced. A failure is fixed in the canonical fixture and re-synced to both platforms, never + * edited here. + * + * The group invite link card tints its band with this colour, so both apps must read the same + * colour from the same hash. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class BlurHashAverageColorVectorTest { + + private fun fixture(): JSONObject = JSONObject( + javaClass.classLoader!! + .getResourceAsStream("blurhash_average.json")!! + .bufferedReader().use { it.readText() } + ) + + @Test + fun `every vector reads the expected average colour`() { + val fixture = fixture() + assertEquals("blurhash-average-color", fixture.getString("algorithm")) + val vectors = fixture.getJSONArray("vectors") + assertTrue(vectors.length() > 0) + + for (i in 0 until vectors.length()) { + val vector = vectors.getJSONObject(i) + val expected = vector.optJSONObject("average")?.let { + (it.getInt("red") shl 16) or (it.getInt("green") shl 8) or it.getInt("blue") + } + assertEquals( + expected, + BlurHash.averageColor(vector.getString("blurHash")), + "vector `${vector.getString("name")}`", + ) + } + } + + @Test + fun `a missing hash has no average colour`() { + assertNull(BlurHash.averageColor(null)) + } +} diff --git a/apps/flipcash/shared/common-ui/src/test/resources/blurhash_average.json b/apps/flipcash/shared/common-ui/src/test/resources/blurhash_average.json new file mode 100644 index 0000000000..797eed6264 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/test/resources/blurhash_average.json @@ -0,0 +1,89 @@ +{ + "algorithm": "blurhash-average-color", + "note": "The DC component of a BlurHash: characters 2-5 as base-83 -> 0xRRGGBB, sRGB bytes, no linearisation. `average` is null when the hash is malformed (shorter than 6, a character outside the alphabet, or a length other than 4 + 2*nx*ny for the size flag in character 0). Reference: gen_blurhash_average.py.", + "vectors": [ + { + "name": "reference-1", + "blurHash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + "average": { + "red": 151, + "green": 150, + "blue": 149 + } + }, + { + "name": "reference-2", + "blurHash": "LGF5]+Yk^6#M@-5c,1J5@[or[Q6.", + "average": { + "red": 131, + "green": 126, + "blue": 152 + } + }, + { + "name": "reference-3", + "blurHash": "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + "average": { + "red": 221, + "green": 217, + "blue": 213 + } + }, + { + "name": "reference-4", + "blurHash": "LKO2?U%2Tw=w]~RBVZRi};RPxuwH", + "average": { + "red": 209, + "green": 178, + "blue": 163 + } + }, + { + "name": "one-component-black", + "blurHash": "000000", + "average": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "name": "one-component-white", + "blurHash": "00TSUA", + "average": { + "red": 255, + "green": 255, + "blue": 255 + } + }, + { + "name": "one-component-mixed", + "blurHash": "006wgO", + "average": { + "red": 58, + "green": 127, + "blue": 194 + } + }, + { + "name": "empty", + "blurHash": "", + "average": null + }, + { + "name": "too-short", + "blurHash": "LEHV6", + "average": null + }, + { + "name": "length-disagrees-with-size-flag", + "blurHash": "LEHV6nWB2yk8pyo0adR*.7kCMdn", + "average": null + }, + { + "name": "out-of-alphabet", + "blurHash": "LEHV6nWB2yk8pyo0adR*.7kCMdéj", + "average": null + } + ] +} From c7cbbd56545b19ab553261d918e1b172a7140415 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 20:46:36 -0400 Subject: [PATCH 3/3] fix(chat): put the balance requirement's amount on its own line The label and the amount now break onto two lines, in the chat header and the group invite card alike, since both read the same strings. --- apps/flipcash/core/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index cf56065d47..6ab9ccce95 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -892,8 +892,8 @@ From Your Contacts Add Contact - Balance Requirement: %1$s of %2$s - Balance Requirement: %1$s + Balance Requirement:\n%1$s of %2$s + Balance Requirement:\n%1$s Flipcash Staff Only Minimum Balance: %1$s of %2$s Minimum Balance: %1$s