diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 9cdc4b221d..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 @@ -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/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/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/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/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/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/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 0262474f4a..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 @@ -2,7 +2,8 @@ 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.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 @@ -56,6 +58,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 +70,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 +86,57 @@ 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 + + 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. - 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) + // Drawn above; unreachable here. + is LinkCard.GroupInvite -> Unit } } } @@ -147,7 +183,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 +210,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 +282,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 +291,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 +300,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 +324,6 @@ private fun UnresolvedTokenCard( mint: Mint, height: Dp, loading: Boolean, - onClick: () -> Unit, ) { val shape = CodeTheme.shapes.medium Box( @@ -307,7 +336,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 +383,6 @@ private fun CashVoucher( spent: Boolean, torn: Boolean, loading: Boolean, - onClick: () -> Unit, stub: @Composable () -> Unit, ) { val corner = CodeTheme.shapes.medium.topStart @@ -366,10 +393,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..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 @@ -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, + ) + } } } @@ -559,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 { @@ -574,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)) } } } @@ -830,7 +855,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 +867,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 +935,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 +973,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 +984,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 +1008,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, @@ -983,6 +1032,36 @@ private fun Preview_TextBubble_LinkCard_Claimed() { ) } +/** + * 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/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)) - } -} 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 + } + ] +}