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
+ }
+ ]
+}