From 127e5a414461a54996b08c793e3754ff57b7883e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:49:01 -0400 Subject: [PATCH 1/9] feat(messenger): draw message links as a card of the linked message A link to /chat/{id}/m/{seq} now gets its own card instead of the group invite card. What the card shows depends on the viewer: - in the chat: the message, opened through the chat's own path so a private group's message is decrypted on device, and its reaction count - a public group they aren't in: GetMessage in FULL_OR_REDACTED; a redacted body is drawn blurred and offers Buy - a private group they aren't in: the group's record and Request - a DM they aren't in: that it is one A deleted or missing message, or a chat the server doesn't have, is Unresolved. Unknown membership is read as not a member, so the card never shows more than the server would hand a stranger. Card states follow Figma node 11396:118, values node 11493:201. --- .../core/src/main/res/values/strings.xml | 19 + .../internal/link/GroupLinkLookup.kt | 33 +- .../internal/link/LinkCardClassifier.kt | 10 + .../messenger/internal/link/LinkCardModule.kt | 2 + .../internal/link/LinkCardResolver.kt | 24 + .../internal/link/MessageLinkLookup.kt | 162 +++++++ .../internal/link/MessageLinkPrefetcher.kt | 3 +- .../internal/link/LinkCardClassifierTest.kt | 59 ++- .../internal/link/MessageLinkLookupTest.kt | 280 +++++++++++ .../flipcash/shared/chat/models/LinkCard.kt | 94 ++++ .../flipcash/shared/chat/ui/LinkCardView.kt | 29 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 5 + .../shared/chat/ui/MessageLinkCard.kt | 436 ++++++++++++++++++ .../flipcash/shared/chat/ChatCoordinator.kt | 9 + .../internal/delegates/MessagingDelegate.kt | 12 + 15 files changed, 1157 insertions(+), 20 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b904d2bc3..4b2ca2145 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1055,6 +1055,25 @@ Show preview · %1$s Group Unavailable + Flipcash + Message + Public group + Private group + Direct message + You + %1$s · %2$s + %1$s sent + %1$s tipped + Attachment + + %1$s reaction + %1$s reactions + + Only members of %1$s can read this message. + Only the two people in this chat can read it. + %1$s · Hosted by %2$s + Open + Request Copy diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/GroupLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/GroupLinkLookup.kt index 6bbd0af73..c5af7a01e 100644 --- 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 @@ -7,6 +7,7 @@ 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.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewMode import com.flipcash.shared.chat.FeaturedGroupsStore @@ -48,28 +49,28 @@ internal class GroupLinkLookup @Inject constructor( // sitting empty through its own GetChat -- the same placeholder a featured group gets. featuredGroups.remember(listOf(chat)) - 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, + requirement = requirement(chat.rules), ) } + /** What [rules] ask of a reader, worded as the invite card words it; null when nothing. */ + suspend fun requirement(rules: ChatRules?): LinkCard.GroupInvite.Requirement? { + val balance = rules.balanceRequirement() + val staffOnly = rules.requiresStaff() + if (balance == null && !staffOnly) return null + val token = balance?.mints?.firstOrNull()?.let { mint -> + tokenCoordinator.getTokenMetadata(Mint(mint.bytes)).getOrNull()?.token + } + return LinkCard.GroupInvite.Requirement( + amount = balance?.amount?.formatted(), + currencyName = token?.takeUnless { it.isReserve }?.brandedName(resources), + staffOnly = staffOnly, + ) + } + class NotAGroup(chatId: ChatId) : IllegalStateException("chat $chatId is not a group") } 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 526d29880..4408b4831 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 @@ -147,6 +147,16 @@ internal class LinkCardClassifier @Inject constructor( }, state = LinkCard.User.State.Loading, ) + // `/chat/{id}/m/{seq}`: the router has already matched the shape and told a group's + // UUID from a DM's base64url id. + is DeeplinkType.ChatMessage -> LinkCard.Message( + url = target, + start = link.start, + end = link.end, + chatId = type.chatId, + messageId = type.messageId, + isGroup = type.isGroup, + ) else -> null } } 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 20bcf4f94..497beb9d2 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 @@ -36,6 +36,7 @@ internal object LinkCardModule { group: GroupLinkLookup, user: UserLinkLookup, web: WebLinkLookup, + message: MessageLinkLookup, memory: LinkCardMemory, ): LinkCardResolver = LinkCardResolver( @@ -48,6 +49,7 @@ internal object LinkCardModule { group = { group(it) }, user = { user(it) }, web = web::invoke, + message = message::invoke, memory = memory, ) } 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 f0d87a0a8..d9cda2afc 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 @@ -54,6 +54,9 @@ internal class LinkCardResolver( private val web: suspend (url: String) -> Result = { Result.failure(IllegalStateException("no web lookup")) }, + private val message: suspend (card: LinkCard.Message) -> Result = { + Result.failure(IllegalStateException("no message lookup")) + }, private val memory: LinkCardMemory = LinkCardMemory(), ) : LinkCardResolution { @@ -75,6 +78,7 @@ internal class LinkCardResolver( private val groupQueries = mutableMapOf>() private val userQueries = mutableMapOf>() private val webQueries = mutableMapOf>>() + private val messageQueries = mutableMapOf>() /** * The answers that have landed, readable without the lock and without suspending — which is the @@ -94,6 +98,12 @@ internal class LinkCardResolver( * query maps above are still per visit, so each visit asks once and writes over what it found. */ private val cashAnswers = ConcurrentHashMap() + + /** + * Message answers are held per visit, like cash: what a card may show turns on the viewer's + * membership and the group's rules, both of which a join or a purchase moves. + */ + private val messageAnswers = ConcurrentHashMap() private val tokenAnswers get() = memory.tokens private val groupAnswers get() = memory.groups private val userAnswers get() = memory.users @@ -122,6 +132,7 @@ internal class LinkCardResolver( is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) } is LinkCard.User -> userAnswers[card.identity]?.let { card.copy(state = it) } is LinkCard.Web -> WebLinks.cacheKey(card.url)?.let { memory.webs[it] }?.let { card.copy(state = it) } + is LinkCard.Message -> messageAnswers[card.key]?.let { card.copy(state = it) } } override suspend fun resolve(card: LinkCard): LinkCard = when (card) { @@ -130,6 +141,7 @@ internal class LinkCardResolver( is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId)) is LinkCard.User -> card.copy(state = userState(card.identity)) is LinkCard.Web -> card.copy(state = webState(card.url)) + is LinkCard.Message -> card.copy(state = messageState(card)) } /** @@ -288,6 +300,18 @@ internal class LinkCardResolver( }.getOrDefault(LinkCard.Web.State.None) } + private suspend fun messageState(card: LinkCard.Message): LinkCard.Message.State = + memoized(messageQueries, card.key) { + message(card).fold( + onSuccess = { state -> state.also { messageAnswers[card.key] = it } }, + onFailure = { forget(messageQueries, card.key); LinkCard.Message.State.Unresolved }, + ) + } + + private data class MessageKey(val chatId: ChatId, val messageId: Long) + + private val LinkCard.Message.key get() = MessageKey(chatId, messageId) + private suspend fun memoized( queries: MutableMap>, key: K, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt new file mode 100644 index 000000000..28fdfd9b7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt @@ -0,0 +1,162 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.GetChatError +import com.flipcash.services.models.GetMessageError +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.ViewMode +import com.flipcash.services.models.handle +import com.flipcash.services.models.nameOrHandle +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.models.LinkCard +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +/** + * Reads what a message link's card may show, which depends on the source chat and the viewer: + * + * - The viewer is in the chat: the message itself, opened on this device if it is end-to-end + * encrypted, with its reaction count. ([LinkCard.Message.State.Resolved]) + * - A public group they aren't in: `GetMessage` in `FULL_OR_REDACTED`, which answers with the text + * when the group's rules let them read and a placeholder when they don't. + * ([LinkCard.Message.State.Gated]) + * - A private group they aren't in: the group's public record only, never the message. + * ([LinkCard.Message.State.LockedGroup]) + * - A DM they aren't in: that it is one. ([LinkCard.Message.State.LockedDirect]) + * + * A deleted message, a message or chat the server doesn't have, or one this device can't open + * answers [LinkCard.Message.State.Unresolved]; a card never draws resolution failing as an error. + * A request that didn't complete fails, so the resolver asks again on the next draw. + * + * Membership is read from the stored chat. A chat with no stored row, or one whose membership is + * unknown, is treated as one the viewer is not in: what the card shows is then what the server + * hands a stranger, so an unknown can never show more than it should. + */ +internal class MessageLinkLookup @Inject constructor( + private val chats: ChatCoordinator, + private val chatController: ChatController, + private val messaging: ChatMessagingController, + private val profiles: ProfileController, + private val userManager: UserManager, + private val groups: GroupLinkLookup, +) { + suspend operator fun invoke(card: LinkCard.Message): Result = runCatching { + val stored = chats.observeMetadata(card.chatId).first() + if (stored?.isMember == true) { + member(stored.metadata, card.messageId) + } else if (card.isGroup) { + nonMemberGroup(card.chatId, card.messageId) + } else { + LinkCard.Message.State.LockedDirect + } + }.recoverCatching { error -> + if (error.meansNothingThere()) LinkCard.Message.State.Unresolved else throw error + } + + private suspend fun member(chat: ChatMetadata, messageId: Long): LinkCard.Message.State { + val message = chats.readMessage(chat.chatId, messageId).getOrThrow() + val body = message.body() ?: return LinkCard.Message.State.Unresolved + return LinkCard.Message.State.Resolved( + source = source(chat), + message = excerpt(message, body, chat), + reactionCount = message.reactions?.reactions?.sumOf { it.count } ?: 0, + ) + } + + private suspend fun nonMemberGroup(chatId: ChatId, messageId: Long): LinkCard.Message.State { + val chat = chatController.getChat(chatId, ViewMode.REDACTED).getOrThrow() + if (chat.type != ChatType.GROUP) return LinkCard.Message.State.Unresolved + if (chat.isPrivate) { + return LinkCard.Message.State.LockedGroup( + source = source(chat), + memberCount = chat.rosterSummary.memberCount, + host = chat.creator?.let { name(it) }, + ) + } + val message = messaging.getMessage(chatId, messageId, ViewMode.FULL_OR_REDACTED).getOrThrow() + val body = message.body() ?: return LinkCard.Message.State.Unresolved + return LinkCard.Message.State.Gated( + source = source(chat), + message = excerpt(message, body, chat = null), + requirement = groups.requirement(chat.rules), + ) + } + + private fun source(chat: ChatMetadata): LinkCard.Message.Source = when (chat.type) { + ChatType.GROUP -> LinkCard.Message.Source( + title = chat.title?.takeIf { it.isNotBlank() }, + kind = if (chat.isPrivate) LinkCard.Message.Kind.PrivateGroup else LinkCard.Message.Kind.PublicGroup, + picture = chat.picture, + pictureAccess = BlobAccessContext.ChatProfile(chat.chatId), + ) + else -> { + val peer = chat.members.firstOrNull { it.userId != userManager.accountId } + LinkCard.Message.Source( + title = peer?.userProfile?.displayLabel(), + kind = LinkCard.Message.Kind.DirectMessage, + picture = peer?.userProfile?.profilePicture, + pictureAccess = BlobAccessContext.profile(peer?.userId), + ) + } + } + + /** + * The sender, named from the chat's stored roster when [chat] has one and from their public + * profile otherwise. An unreadable name is left off rather than failing the card. + */ + private suspend fun excerpt( + message: ChatMessage, + body: LinkCard.Message.Body, + chat: ChatMetadata?, + ): LinkCard.Message.Excerpt { + val sender = message.senderId + val isFromSelf = message.isFromSelf || (sender != null && sender == userManager.accountId) + val author = sender?.let { id -> + chat?.members?.firstOrNull { it.userId == id }?.userProfile?.displayLabel() ?: name(id) + } + return LinkCard.Message.Excerpt( + author = author, + isFromSelf = isFromSelf, + sentAt = message.timestamp, + body = if (message.redacted && body is LinkCard.Message.Body.Text) body.copy(redacted = true) else body, + ) + } + + private suspend fun name(userId: ID): String? = + profiles.getProfileForUser(userId).getOrNull()?.displayLabel() + + private fun UserProfile.displayLabel(): String? = nameOrHandle(displayName, handle) + + /** The card's body for this message, or null when there is nothing to show: deleted or sealed. */ + private fun ChatMessage.body(): LinkCard.Message.Body? = content.singleOrNull()?.body() + + private fun MessageContent.body(): LinkCard.Message.Body? = when (this) { + is MessageContent.Text -> LinkCard.Message.Body.Text(text) + is MessageContent.Reply -> content.singleOrNull()?.body() + is MessageContent.Media -> caption?.let { LinkCard.Message.Body.Text(it.text) } ?: LinkCard.Message.Body.Other + is MessageContent.Cash -> LinkCard.Message.Body.Cash( + amount = amount.formatted(), + tipped = action == MessageContent.Cash.Action.TIPPED, + ) + is MessageContent.Widget, is MessageContent.System -> LinkCard.Message.Body.Other + is MessageContent.Deleted, is MessageContent.Encrypted -> null + } + + private fun Throwable.meansNothingThere(): Boolean = when (this) { + is GetMessageError.NotFound, + is GetMessageError.Denied, + is GetChatError.NotFound, + is GetChatError.Denied -> true + else -> false + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt index 82c16f728..489c31242 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt @@ -73,7 +73,8 @@ internal class MessageLinkPrefetcher( is LinkCard.Web -> if (!webLinks) null else WebLinks.cacheKey(card.url) ?.takeUnless { it in memory.webs } ?.let { key -> start(key) { web(card.url).onSuccess { memory.putWeb(key, it) } } } - is LinkCard.Cash, is LinkCard.TokenInfo -> null + // Held per visit, since who can read it changes with membership; resolved when drawn. + is LinkCard.Cash, is LinkCard.TokenInfo, is LinkCard.Message -> null } private fun start(key: Any, block: suspend () -> Unit): Deferred = 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 c45475aa6..dde8785d4 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 @@ -17,6 +17,7 @@ import org.robolectric.annotation.Config import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -44,8 +45,9 @@ 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) + // As `AppRouter` does: a message link is matched first, on its exact shape; + // otherwise the id segment is checked and anything after it is not. + "chat" -> link.messageLinkForTest() ?: link.pathSegmentsForTest().getOrNull(1) ?.let { runCatching { UUID.fromString(it) }.getOrNull() } ?.let { DeeplinkType.GroupChatInvite(ChatId(it.bytes)) } "tip" -> link.pathSegmentsForTest().getOrNull(1) @@ -58,6 +60,17 @@ class LinkCardClassifierTest { override fun dispatch(deepLink: DeepLink) = error("not used in classification tests") } + private fun DeepLink.messageLinkForTest(): DeeplinkType.ChatMessage? { + val segments = pathSegmentsForTest() + if (segments.size != 4 || segments[2] != "m") return null + val seq = segments[3].toLongOrNull() ?: return null + val uuid = runCatching { UUID.fromString(segments[1]) }.getOrNull() + val chatId = uuid?.let { ChatId(it.bytes) } + ?: runCatching { ChatId(java.util.Base64.getUrlDecoder().decode(segments[1]).toList()) }.getOrNull() + ?: return null + return DeeplinkType.ChatMessage(chatId, seq, isGroup = uuid != null) + } + private fun fixture(): JSONObject = JSONObject( javaClass.classLoader!! .getResourceAsStream("link_detection.json")!! @@ -208,6 +221,48 @@ class LinkCardClassifierTest { assertNull(classifier.firstCard(listOf(DetectedUrl(0, url.length, url)))) } + @Test + fun `a group message link becomes a message card, not an invite`() { + val url = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162/m/42" + + val card = cardFor(url) + + val message = assertIs(card) + assertEquals(ChatId(UUID.fromString("6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162").bytes), message.chatId) + assertEquals(42L, message.messageId) + assertTrue(message.isGroup) + assertEquals(url, message.url) + assertEquals(LinkCard.Message.State.Loading, message.state) + } + + @Test + fun `a DM message link becomes a message card`() { + val dmId = List(32) { it.toByte() } + val encoded = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(dmId.toByteArray()) + val url = "https://app.flipcash.com/chat/$encoded/m/7" + + val message = assertIs(cardFor(url)) + + assertEquals(ChatId(dmId), message.chatId) + assertEquals(7L, message.messageId) + assertFalse(message.isGroup) + } + + /** The invite card wants the bare `/chat/{uuid}`, so a message link cut short gets no card. */ + @Test + fun `a message link with no number gets no card`() { + val url = "https://app.flipcash.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162/m" + + assertNull(cardFor(url)) + } + + @Test + fun `a message link off the app host is a web page`() { + val url = "https://example.com/chat/6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162/m/42" + + assertIs(cardFor(url)) + } + // Person cards private fun cardFor(url: String): LinkCard? = diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt new file mode 100644 index 000000000..a576621d7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt @@ -0,0 +1,280 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.GetChatError +import com.flipcash.services.models.GetMessageError +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.ReactionSummary +import com.flipcash.services.models.chat.ViewMode +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatMembership +import com.flipcash.shared.chat.models.LinkCard +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Fiat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import java.io.IOException +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +class MessageLinkLookupTest { + + private val groupId = ChatId(UUID.fromString("6f1c3a9e-2b7d-4e0a-9c55-1d2e3f405162").bytes) + private val dmId = ChatId(List(32) { it.toByte() }) + private val myId = UUID.fromString("2b0b4d1e-9f3e-4c21-9f1a-6d5f7c8e9a0b").bytes + private val senderId = UUID.fromString("8cec60c8-afbb-88a8-96fe-64cc825f5322").bytes + private val sentAt = Instant.fromEpochSeconds(1_760_000_000) + + private fun card(chatId: ChatId = groupId, isGroup: Boolean = true) = LinkCard.Message( + url = "https://app.flipcash.com/chat/x/m/42", + start = 0, + end = 10, + chatId = chatId, + messageId = 42, + isGroup = isGroup, + ) + + private fun profile(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + joinedAt = null, + userId = null, + username = null, + ) + + private fun chat( + chatId: ChatId = groupId, + type: ChatType = ChatType.GROUP, + isPrivate: Boolean = false, + members: List = listOf(ChatMember(senderId, profile("Ada"), emptyList())), + ) = ChatMetadata( + chatId = chatId, + type = type, + members = members, + lastMessage = null, + lastActivity = sentAt, + title = "Moony", + isPrivate = isPrivate, + creator = senderId, + ) + + private fun message( + content: MessageContent = MessageContent.Text("gm"), + redacted: Boolean = false, + reactions: ReactionSummary? = null, + ) = ChatMessage( + messageId = 42, + senderId = senderId, + content = listOf(content), + timestamp = sentAt, + unreadSeq = 0, + reactions = reactions, + redacted = redacted, + ) + + private class Deps( + val chats: ChatCoordinator = mock(), + val chatController: ChatController = mock(), + val messaging: ChatMessagingController = mock(), + val profiles: ProfileController = mock(), + val groups: GroupLinkLookup = mock(), + ) + + private fun lookup(deps: Deps) = MessageLinkLookup( + chats = deps.chats, + chatController = deps.chatController, + messaging = deps.messaging, + profiles = deps.profiles, + userManager = mock { on { accountId } doReturn myId }, + groups = deps.groups, + ) + + private fun stored(metadata: ChatMetadata?, isMember: Boolean?): ChatCoordinator = mock { + on { observeMetadata(any()) } doReturn flowOf(metadata?.let { ChatMembership(it, isMember) }) + } + + @Test + fun `a member sees the message with its reaction count`() = runTest { + val reaction = mock { on { count } doReturn 3 } + val other = mock { on { count } doReturn 2 } + val chats = stored(chat(), isMember = true) + org.mockito.kotlin.whenever(chats.readMessage(groupId, 42)) + .thenReturn(Result.success(message(reactions = ReactionSummary(42, listOf(reaction, other))))) + + val state = lookup(Deps(chats = chats))(card()).getOrThrow() + + val resolved = assertIs(state) + assertEquals(5L, resolved.reactionCount) + assertEquals("Ada", resolved.message.author) + assertEquals(LinkCard.Message.Body.Text("gm"), resolved.message.body) + assertEquals(LinkCard.Message.Kind.PublicGroup, resolved.source.kind) + } + + @Test + fun `a member of a private group reads the message through the chat, not GetMessage`() = runTest { + val deps = Deps(chats = stored(chat(isPrivate = true), isMember = true)) + org.mockito.kotlin.whenever(deps.chats.readMessage(groupId, 42)).thenReturn(Result.success(message())) + + val state = lookup(deps)(card()).getOrThrow() + + assertEquals( + LinkCard.Message.Kind.PrivateGroup, + assertIs(state).source.kind, + ) + verify(deps.messaging, never()).getMessage(any(), any(), any()) + } + + @Test + fun `a non-member of a public group gets the message as GetMessage hands a stranger`() = runTest { + val deps = Deps(chats = stored(null, isMember = null)) + org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) + .thenReturn(Result.success(chat())) + org.mockito.kotlin.whenever(deps.messaging.getMessage(groupId, 42, ViewMode.FULL_OR_REDACTED)) + .thenReturn(Result.success(message(content = MessageContent.Text("•••"), redacted = true))) + org.mockito.kotlin.whenever(deps.profiles.getProfileForUser(any())) + .thenReturn(Result.success(profile("Ada"))) + + val state = lookup(deps)(card()).getOrThrow() + + val gated = assertIs(state) + assertEquals(LinkCard.Message.Body.Text("•••", redacted = true), gated.message.body) + assertEquals("Ada", gated.message.author) + } + + @Test + fun `a non-member of a private group sees the group's record and never the message`() = runTest { + val deps = Deps(chats = stored(chat(isPrivate = true), isMember = false)) + org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) + .thenReturn(Result.success(chat(isPrivate = true))) + org.mockito.kotlin.whenever(deps.profiles.getProfileForUser(any())) + .thenReturn(Result.success(profile("Ada"))) + + val state = lookup(deps)(card()).getOrThrow() + + val locked = assertIs(state) + assertEquals("Ada", locked.host) + verify(deps.messaging, never()).getMessage(any(), any(), any()) + } + + @Test + fun `unknown membership is treated as not a member`() = runTest { + val deps = Deps(chats = stored(chat(isPrivate = true), isMember = null)) + org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) + .thenReturn(Result.success(chat(isPrivate = true))) + org.mockito.kotlin.whenever(deps.profiles.getProfileForUser(any())) + .thenReturn(Result.failure(IOException())) + + val state = lookup(deps)(card()).getOrThrow() + + assertIs(state) + verify(deps.chats, never()).readMessage(any(), any()) + } + + @Test + fun `a DM the viewer isn't in is locked without asking the server`() = runTest { + val deps = Deps(chats = stored(null, isMember = null)) + + val state = lookup(deps)(card(chatId = dmId, isGroup = false)).getOrThrow() + + assertEquals(LinkCard.Message.State.LockedDirect, state) + verify(deps.chatController, never()).getChat(any(), any()) + verify(deps.messaging, never()).getMessage(any(), any(), any()) + } + + @Test + fun `a member's DM names the peer`() = runTest { + val dm = chat( + chatId = dmId, + type = ChatType.CONTACT_DM, + members = listOf( + ChatMember(myId, profile("Me"), emptyList()), + ChatMember(senderId, profile("Ada"), emptyList()), + ), + ) + val deps = Deps(chats = stored(dm, isMember = true)) + org.mockito.kotlin.whenever(deps.chats.readMessage(dmId, 42)).thenReturn(Result.success(message())) + + val state = lookup(deps)(card(chatId = dmId, isGroup = false)).getOrThrow() + + val source = assertIs(state).source + assertEquals("Ada", source.title) + assertEquals(LinkCard.Message.Kind.DirectMessage, source.kind) + } + + @Test + fun `a deleted message is unresolved`() = runTest { + val deps = Deps(chats = stored(chat(), isMember = true)) + org.mockito.kotlin.whenever(deps.chats.readMessage(groupId, 42)) + .thenReturn(Result.success(message(content = MessageContent.Deleted(sentAt, senderId)))) + + assertEquals(LinkCard.Message.State.Unresolved, lookup(deps)(card()).getOrThrow()) + } + + @Test + fun `a message the server doesn't have is unresolved`() = runTest { + val deps = Deps(chats = stored(null, isMember = null)) + org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) + .thenReturn(Result.success(chat())) + org.mockito.kotlin.whenever(deps.messaging.getMessage(groupId, 42, ViewMode.FULL_OR_REDACTED)) + .thenReturn(Result.failure(GetMessageError.NotFound())) + + assertEquals(LinkCard.Message.State.Unresolved, lookup(deps)(card()).getOrThrow()) + } + + @Test + fun `a group the server doesn't have is unresolved`() = runTest { + val deps = Deps(chats = stored(null, isMember = null)) + org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) + .thenReturn(Result.failure(GetChatError.NotFound())) + + assertEquals(LinkCard.Message.State.Unresolved, lookup(deps)(card()).getOrThrow()) + } + + @Test + fun `a request that didn't complete fails, so the resolver asks again`() = runTest { + val deps = Deps(chats = stored(null, isMember = null)) + org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) + .thenReturn(Result.failure(IOException("offline"))) + + assertTrue(lookup(deps)(card()).isFailure) + } + + @Test + fun `a payment shows its amount`() = runTest { + val amount = Fiat(5.0) + val cash = MessageContent.Cash( + intentId = senderId, + amount = amount, + mint = mock(), + action = MessageContent.Cash.Action.TIPPED, + ) + val deps = Deps(chats = stored(chat(), isMember = true)) + org.mockito.kotlin.whenever(deps.chats.readMessage(groupId, 42)) + .thenReturn(Result.success(message(content = cash))) + + val body = assertIs(lookup(deps)(card()).getOrThrow()).message.body + + assertEquals(LinkCard.Message.Body.Cash(amount = amount.formatted(), tipped = true), body) + } +} 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 ee4ac6bcb..8adceccae 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 @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.models import com.flipcash.services.models.UserProfile import com.flipcash.services.models.asHandle +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem import com.getcode.opencode.model.core.ID @@ -258,4 +259,97 @@ sealed interface LinkCard { ) : State } } + + /** + * A link to one message, `app.flipcash.com/chat/{chatId}/m/{seq}`. + * + * What the card may show depends on the source chat and the viewer, never on the link: a member + * reads the message, a public group's non-member reads what the group's rules let them, and a + * private group or DM shows a stranger nothing of the message at all. [isGroup] is read off the + * id's form (UUID for a group, base64url for a DM) so [State.Loading] already knows which. + */ + data class Message( + override val url: String, + override val start: Int, + override val end: Int, + val chatId: ChatId, + val messageId: Long, + val isGroup: Boolean, + val state: State = State.Loading, + ) : LinkCard { + + sealed interface State { + /** The lookup is out. Drawn as the skeleton, shimmering. */ + data object Loading : State + + /** + * Nothing to show beyond the URL: the lookup failed, or the message is deleted or not + * there. Drawn as the skeleton at rest; resolution failing is never an error on a card. + */ + data object Unresolved : State + + /** The viewer is in the chat, or the chat is a public group whose rules let them read. */ + data class Resolved( + val source: Source, + val message: Excerpt, + /** The source message's reactions, counted; paid totals are left off the card. */ + val reactionCount: Long, + ) : State + + /** + * A public group the viewer hasn't joined. [message] is redacted when they are below + * the group's requirement, in which case the card offers Buy rather than Join. + */ + data class Gated( + val source: Source, + val message: Excerpt, + val requirement: GroupInvite.Requirement?, + ) : State + + /** A private group the viewer isn't in: the group's public record, and Request. */ + data class LockedGroup( + val source: Source, + val memberCount: Long, + /** The creator's name, or null when it couldn't be read. */ + val host: String?, + ) : State + + /** A DM the viewer isn't in. Only that it is one; it never unlocks. */ + data object LockedDirect : State + } + + /** The chat the message is in, as the card's header names it. */ + data class Source( + /** The group's title or the DM peer's name; null for an untitled group. */ + val title: String?, + val kind: Kind, + val picture: MediaItem?, + /** How [picture] is read: the group's public profile, or the DM peer's profile. */ + val pictureAccess: BlobAccessContext, + ) + + enum class Kind { DirectMessage, PublicGroup, PrivateGroup } + + data class Excerpt( + /** The sender's name, or null when it couldn't be read. */ + val author: String?, + val isFromSelf: Boolean, + val sentAt: kotlin.time.Instant, + val body: Body, + ) + + sealed interface Body { + /** + * The message's text. When [redacted] the server sent a placeholder in its place, + * which the card draws blurred and never copies or quotes. + */ + data class Text(val text: String, val redacted: Boolean = false) : Body + + /** A payment: the amount, pre-formatted, and whether it was sent or tipped. */ + data class Cash(val amount: String, val tipped: Boolean) : Body + + /** A photo or anything else with no text to excerpt. */ + data object Other : Body + } + } } 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 76749cb92..1505dc18a 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 @@ -147,6 +147,33 @@ internal fun LinkCardView( return } + if (live is LinkCard.Message) { + // Sized by its content, not the voucher's aspect: the states run from a header and one line + // to a four-line excerpt over a button. + MessageLinkCard( + card = live, + shape = shape, + // A DM the reader isn't in opens nothing they could read. + onOpen = onClick?.takeUnless { live.state == LinkCard.Message.State.LockedDirect } + ?.let { click -> { click(live) } }, + modifier = modifier + .semantics { linkCardShape = shape } + .addIf(onClick != null || onLongClick != null) { + Modifier + .clip(shape) + .combinedClickable( + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + onClick = { + if (live.state != LinkCard.Message.State.LockedDirect) onClick?.invoke(live) + }, + hapticFeedbackEnabled = false, + ) + }, + ) + 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. @@ -178,7 +205,7 @@ internal fun LinkCardView( ) is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height, shape = shape) // Drawn above; unreachable here. - is LinkCard.GroupInvite, is LinkCard.User, is LinkCard.Web -> Unit + is LinkCard.GroupInvite, is LinkCard.User, is LinkCard.Web, is LinkCard.Message -> Unit } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 8d4e3013b..f2ee6657e 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 @@ -42,6 +42,7 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -911,8 +912,12 @@ private fun BareLinkCard( @Composable private fun rememberLinkCardClick(): (LinkCard) -> Unit { val actionHandler = LocalChatActionHandler.current + val uriHandler = LocalUriHandler.current return { card -> when (card) { + // The transcript's handler follows a message link in the app: a jump in place when it + // points into this chat, a push with the way back otherwise. + is LinkCard.Message -> uriHandler.openUri(card.url) // The chat opens it, because only the chat knows whether this reader may collect it. // A voucher already being claimed would only start the same claim again. is LinkCard.Cash -> if ((card.state as? LinkCard.Cash.State.Resolved)?.claim != LinkCard.Cash.Claim.Claiming) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt new file mode 100644 index 000000000..31cd0cd98 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt @@ -0,0 +1,436 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +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.semantics.clearAndSetSemantics +import androidx.compose.ui.text.font.FontWeight +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 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.ChatId +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCard.Message.Body +import com.flipcash.shared.chat.models.LinkCard.Message.Kind +import com.flipcash.shared.chat.models.LinkCard.Message.State +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours + +/** + * A link to one message, drawn as a panel naming the chat it came from (node 11396:118). + * + * Every state shares one frame: the chat's header, then whatever the viewer may see of the message, + * then the footer or the button. What fills it is decided upstream, by who is reading and where the + * message lives, never by the card; see [LinkCard.Message.State]. + * + * [onOpen] takes the card's tap and its button alike. Both follow the link, and the chat it lands + * on runs its own gate, so Join, Buy and Request start the flows the group's screen already has + * rather than a second copy of them here. Null leaves the card inert. + * + * A redacted body is the server's placeholder, not the message. It is drawn blurred and kept out of + * the semantics tree, so neither a screen reader nor a copy can lift it. + */ +@Composable +internal fun MessageLinkCard( + card: LinkCard.Message, + shape: CornerBasedShape, + onOpen: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val state = card.state + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(MessageLinkCardDefaults.FILL) + .border(CodeTheme.dimens.border, MessageLinkCardDefaults.STROKE, shape) + .padding(MessageLinkCardDefaults.PADDING), + verticalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.GAP), + ) { + when (state) { + State.Loading, State.Unresolved -> Skeleton(card, shimmering = state == State.Loading) + is State.Resolved -> { + Header(state.source) + Excerpt(state.message) + Footer( + leading = state.reactionCount.takeIf { it > 0 }?.let { + pluralStringResource(R.plurals.label_messageLink_reactions, it.toInt(), it.toString()) + }, + onOpen = onOpen, + ) + } + is State.Gated -> { + Header(state.source) + Excerpt(state.message) + val redacted = (state.message.body as? Body.Text)?.redacted == true + if (redacted) state.requirement?.let { RequirementLine(it) } + CardButton( + text = stringResource(if (redacted) R.string.action_buy else R.string.action_join), + onClick = onOpen, + ) + } + is State.LockedGroup -> { + Header(state.source) + Text( + text = stringResource( + R.string.label_messageLink_lockedGroup, + state.source.title ?: stringResource(R.string.label_linkCard_untitledGroup), + ), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + val members = pluralStringResource( + R.plurals.subtitle_chatMemberCount, + state.memberCount.toInt(), + state.memberCount.toString(), + ) + Text( + text = state.host + ?.let { stringResource(R.string.label_messageLink_membersHostedBy, members, it) } + ?: members, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + CardButton(text = stringResource(R.string.action_messageLink_request), onClick = onOpen) + } + State.LockedDirect -> { + // Only that it is a DM. Who is in it is part of what stays locked. + HeaderRow(title = stringResource(R.string.label_messageLink_kindDirect), kind = null) { + PlaceholderAvatar() + } + Text( + text = stringResource(R.string.label_messageLink_lockedDirect), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } + } + } +} + +/** + * Loading and Unresolved: the frame with nothing in it, so nothing moves when the answer lands. The + * id's form says group or DM before any lookup, so the header can say which kind it is. + */ +@Composable +private fun Skeleton(card: LinkCard.Message, shimmering: Boolean) { + HeaderRow( + title = stringResource(R.string.label_messageLink_unresolvedTitle), + kind = stringResource(R.string.label_messageLink_message), + ) { PlaceholderAvatar() } + val bar = Modifier + .height(MessageLinkCardDefaults.BAR_HEIGHT) + .let { if (shimmering) it.shimmer(CircleShape) else it.background(MessageLinkCardDefaults.BAR, CircleShape) } + Spacer(Modifier.height(2.dp)) + Box(bar.fillMaxWidth()) + Box(bar.fillMaxWidth(0.6f)) +} + +@Composable +private fun Header(source: LinkCard.Message.Source) { + val title = source.title ?: stringResource(R.string.label_linkCard_untitledGroup) + HeaderRow(title = title, kind = stringResource(source.kind.label())) { + ContactAvatar( + image = source.picture, + displayName = title, + access = source.pictureAccess, + modifier = Modifier + .size(MessageLinkCardDefaults.AVATAR) + .clip(CircleShape), + ) + } +} + +@Composable +private fun HeaderRow(title: String, kind: String?, avatar: @Composable () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.HEADER_GAP), + ) { + avatar() + Text( + modifier = Modifier.weight(1f, fill = false), + text = title, + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + kind?.let { + Text( + text = it, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } +} + +@Composable +private fun PlaceholderAvatar() { + Box( + Modifier + .size(MessageLinkCardDefaults.AVATAR) + .background(MessageLinkCardDefaults.BAR, CircleShape), + ) +} + +@Composable +private fun Excerpt(excerpt: LinkCard.Message.Excerpt) { + val author = when { + excerpt.isFromSelf -> stringResource(R.string.label_messageLink_you) + else -> excerpt.author + } + val body = excerpt.body + val byline = when { + body is Body.Cash && author != null -> stringResource( + if (body.tipped) R.string.label_messageLink_tipped else R.string.label_messageLink_sent, + author, + ) + else -> author + } + val time = formatLastActivity(excerpt.sentAt) + Text( + text = byline?.let { stringResource(R.string.label_messageLink_byline, it, time) } ?: time, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + when (body) { + is Body.Text -> Text( + modifier = Modifier.then( + if (body.redacted) { + Modifier + .blur(MessageLinkCardDefaults.REDACTED_BLUR, BlurredEdgeTreatment.Unbounded) + .clearAndSetSemantics {} + } else { + Modifier + } + ), + text = body.text, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = MessageLinkCardDefaults.BODY_LINES, + overflow = TextOverflow.Ellipsis, + ) + is Body.Cash -> Text( + text = body.amount, + style = CodeTheme.typography.displayExtraSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) + Body.Other -> Text( + text = stringResource(R.string.label_messageLink_attachment), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } +} + +@Composable +private fun RequirementLine(requirement: LinkCard.GroupInvite.Requirement) { + val amount = requirement.amount ?: return + Text( + 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, + ) +} + +@Composable +private fun Footer(leading: String?, onOpen: (() -> Unit)?) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = leading.orEmpty(), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + if (onOpen != null) { + Text( + text = stringResource(R.string.action_messageLink_open), + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.textMain, + ) + } + } +} + +@Composable +private fun CardButton(text: String, onClick: (() -> Unit)?) { + CodeButton( + modifier = Modifier + .padding(top = MessageLinkCardDefaults.GAP) + .fillMaxWidth() + .height(MessageLinkCardDefaults.BUTTON_HEIGHT), + text = text, + buttonState = ButtonState.Filled, + overrideContentPadding = true, + contentPadding = GroupInviteCardDefaults.NO_PADDING, + enabled = onClick != null, + onClick = { onClick?.invoke() }, + ) +} + +private fun Kind.label(): Int = when (this) { + Kind.DirectMessage -> R.string.label_messageLink_kindDirect + Kind.PublicGroup -> R.string.label_messageLink_kindPublicGroup + Kind.PrivateGroup -> R.string.label_messageLink_kindPrivateGroup +} + +/** Node 11493:201, named after iOS's tokens there. */ +internal object MessageLinkCardDefaults { + /** Panel fill: white at 8%. */ + val FILL = Color.White.copy(alpha = 0.08f) + + /** Panel stroke: white at 10%, the group card's. */ + val STROKE = Color.White.copy(alpha = 0.1f) + + /** Skeleton bars and the placeholder avatar. */ + val BAR = Color.White.copy(alpha = 0.12f) + + val SHAPE = RoundedCornerShape(12.dp) + val PADDING = 12.dp + val GAP = 6.dp + val HEADER_GAP = 6.dp + val AVATAR = 20.dp + val BAR_HEIGHT = 10.dp + val BUTTON_HEIGHT = 44.dp + val REDACTED_BLUR = 6.dp + const val BODY_LINES = 4 +} + +// region Previews + +private const val PREVIEW_MESSAGE_URL = "https://app.flipcash.com/chat/8cec60c8-afbb-88a8-96fe-64cc825f5322/m/42" + +private val previewSource = LinkCard.Message.Source( + title = "Moony", + kind = Kind.PublicGroup, + picture = null, + pictureAccess = BlobAccessContext.ChatProfile(ChatId(ByteArray(16))), +) + +private fun previewExcerpt(body: Body) = LinkCard.Message.Excerpt( + author = "Vincent", + isFromSelf = false, + sentAt = Clock.System.now() - 2.hours, + body = body, +) + +private fun previewMessageCard(state: State) = LinkCard.Message( + url = PREVIEW_MESSAGE_URL, + start = 0, + end = PREVIEW_MESSAGE_URL.length, + chatId = ChatId(ByteArray(16)), + messageId = 42, + isGroup = true, + state = state, +) + +@Composable +private fun PreviewMessageCard(state: State) { + Box(Modifier.width(312.dp).padding(8.dp)) { + MessageLinkCard( + card = previewMessageCard(state), + shape = MessageLinkCardDefaults.SHAPE, + onOpen = {}, + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MessageLink_Resolved() = PreviewMessageCard( + State.Resolved( + source = previewSource, + message = previewExcerpt(Body.Text("Curve just crossed \$1M market cap. Thanks everyone who held through the dip.")), + reactionCount = 212, + ) +) + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MessageLink_GatedRedacted() = PreviewMessageCard( + State.Gated( + source = previewSource, + message = previewExcerpt(Body.Text("Placeholder text standing in for the message", redacted = true)), + requirement = LinkCard.GroupInvite.Requirement(amount = "\$10", currencyName = "MOONY", staffOnly = false), + ) +) + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MessageLink_LockedGroup() = PreviewMessageCard( + State.LockedGroup( + source = previewSource.copy(title = "Ballers", kind = Kind.PrivateGroup), + memberCount = 48, + host = "Chloe", + ) +) + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MessageLink_Cash() = PreviewMessageCard( + State.Resolved( + source = previewSource, + message = previewExcerpt(Body.Cash(amount = "\$20.00", tipped = true)), + reactionCount = 0, + ) +) + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MessageLink_LockedDirect() = PreviewMessageCard(State.LockedDirect) + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MessageLink_Unresolved() = PreviewMessageCard(State.Unresolved) + +// endregion diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index da89a5a35..9af84c0cb 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -477,6 +477,15 @@ interface MessagingOperations { */ suspend fun openPushedChatMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): ChatMessage? + /** + * [messageId] in a chat the viewer belongs to, readable: the stored copy when there is one, + * otherwise fetched and, if it is end-to-end encrypted, opened on this device. Nothing is stored. + * + * Fails with the fetch's error (`GetMessageError.NotFound`, `Denied`). Succeeds with a message + * that is still sealed when this device can't open it; the caller checks [ChatMessage.encryption]. + */ + suspend fun readMessage(chatId: ChatId, messageId: Long): Result + /** * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index b1f93e7b0..22ff62f3d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -412,6 +412,18 @@ class MessagingDelegate @Inject constructor( return opened.takeIf { it.encryption is MessageEncryption.Decrypted } } + override suspend fun readMessage(chatId: ChatId, messageId: Long): Result = runCatching { + messageDataSource.getMessage(chatId, messageId)?.let { return@runCatching it } + val fetched = messagingController.getMessage(chatId, messageId).getOrThrow() + val opener = incoming + val selfId = userManager.accountId + if (opener == null || selfId == null || fetched.content.singleOrNull() !is MessageContent.Encrypted) { + return@runCatching fetched + } + val peerId = if (fetched.senderId == selfId) getOtherMember(chatId)?.userId else null + opener.open(chatId, selfId, peerId, listOf(fetched), stored = { null }).single() + } + /** Text, photos and replies to either are what DMs encrypt; anything else keeps the server's body. */ private fun MessageContent.pushText(): String? = when (this) { is MessageContent.Text -> text From d8a857602cbc57dc7baf384ae5fc986fed147af5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 13:59:27 -0400 Subject: [PATCH 2/9] fix(chat-ui): cover redacted message text where blur is unavailable Modifier.blur draws nothing below Android 12 and minSdk is 29, so on Android 10 and 11 the redacted excerpt would show in plain text. Cover it with a solid block there instead. Adds a Robolectric test that renders every card state to build/screenshots/message_link_card.png for comparison with the design. --- .../shared/chat/ui/MessageLinkCard.kt | 17 ++- .../chat/ui/MessageLinkCardScreenshotTest.kt | 118 ++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt index 31cd0cd98..8f6e7dae9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur +import android.os.Build import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource @@ -231,17 +232,20 @@ private fun Excerpt(excerpt: LinkCard.Message.Excerpt) { when (body) { is Body.Text -> Text( modifier = Modifier.then( - if (body.redacted) { - Modifier + when { + !body.redacted -> Modifier + canBlur -> Modifier .blur(MessageLinkCardDefaults.REDACTED_BLUR, BlurredEdgeTreatment.Unbounded) .clearAndSetSemantics {} - } else { - Modifier + // `blur` draws nothing below Android 12; cover the text with a solid block instead. + else -> Modifier + .background(CodeTheme.colors.textSecondary.copy(alpha = 0.2f), RoundedCornerShape(4.dp)) + .clearAndSetSemantics {} } ), text = body.text, style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, + color = if (body.redacted && !canBlur) Color.Transparent else CodeTheme.colors.textMain, maxLines = MessageLinkCardDefaults.BODY_LINES, overflow = TextOverflow.Ellipsis, ) @@ -434,3 +438,6 @@ private fun Preview_MessageLink_LockedDirect() = PreviewMessageCard(State.Locked private fun Preview_MessageLink_Unresolved() = PreviewMessageCard(State.Unresolved) // endregion + +/** `Modifier.blur` needs RenderEffect, which arrived in Android 12. */ +private val canBlur: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt new file mode 100644 index 000000000..01d9dbeb4 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt @@ -0,0 +1,118 @@ +package com.flipcash.shared.chat.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashTheme +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCard.Message.Body +import com.flipcash.shared.chat.models.LinkCard.Message.Excerpt +import com.flipcash.shared.chat.models.LinkCard.Message.Kind +import com.flipcash.shared.chat.models.LinkCard.Message.Source +import com.flipcash.shared.chat.models.LinkCard.Message.State +import com.getcode.theme.CodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import kotlin.time.Instant + +/** + * Renders every message card state to `build/screenshots/message_link_card.png`, to set beside the + * card component set (Figma node 11396:118). Not an assertion test. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = "w402dp-h2200dp-xhdpi") +class MessageLinkCardScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val chatId = ChatId(List(16) { it.toByte() }) + private val sentAt = Instant.fromEpochSeconds(1_760_000_000) + + private fun source(kind: Kind, title: String = "Moony") = + Source(title = title, kind = kind, picture = null, pictureAccess = BlobAccessContext.ChatProfile(chatId)) + + private fun excerpt(body: Body, author: String = "Ada") = + Excerpt(author = author, isFromSelf = false, sentAt = sentAt, body = body) + + private val text = + Body.Text("The curve flattens out past the first thousand holders, so early buys move the price most.") + + private val states = listOf( + State.Loading, + State.Unresolved, + State.Resolved(source(Kind.PublicGroup), excerpt(text), reactionCount = 12), + State.Resolved(source(Kind.DirectMessage, "Ada"), excerpt(Body.Cash("$5.00", tipped = true)), reactionCount = 0), + State.Gated(source(Kind.PublicGroup), excerpt(text), requirement = null), + State.Gated( + source(Kind.PublicGroup), + excerpt(text.copy(redacted = true)), + requirement = LinkCard.GroupInvite.Requirement(amount = "$10", currencyName = "Moony", staffOnly = false), + ), + State.LockedGroup(source(Kind.PrivateGroup, "Inner Circle"), memberCount = 42, host = "Ada"), + State.LockedDirect, + ) + + @Test + fun rendersEveryState() = render(states, "message_link_card.png") + + private fun render(states: List, fileName: String) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashTheme { + Column( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + states.forEach { state -> + MessageLinkCard( + card = LinkCard.Message( + url = "https://app.flipcash.com/chat/x/m/42", + start = 0, + end = 10, + chatId = chatId, + messageId = 42, + isGroup = true, + state = state, + ), + shape = RoundedCornerShape(12.dp), + onOpen = {}, + modifier = Modifier.width(300.dp), + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width, root.height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, fileName) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } +} From e68712643c441c929c7b72f9e9e693cadfdd1cdf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 21:33:25 -0400 Subject: [PATCH 3/9] fix(chat-ui): size a message card at the cash card's width The card filled the transcript bubble width, so it ran wider than a cash card and changed width against nothing. It now takes the cash card's fraction of the row in every state, on the sender's side. --- .../main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 f2ee6657e..8485230af 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 @@ -147,6 +147,7 @@ fun ContentBubble( rowWidth = maxWidth, rowInset = CodeTheme.dimens.inset, ) + val messageCardWidth = maxWidth * CASH_CARD_WIDTH_FRACTION // 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. @@ -179,7 +180,9 @@ fun ContentBubble( card = card, isFromSelf = item.isFromSelf, position = position, - maxWidth = bubbleMaxWidth, + // A message card takes the cash card's width rather than a bubble's, so its + // states read as one fixed card against the prose either side (iOS matches). + maxWidth = if (card is LinkCard.Message) messageCardWidth else bubbleMaxWidth, quote = quote, onQuoteClick = onQuoteClick, onQuoteLongClick = onLongClick?.takeIf { interactive }, From 36686838f86ba84d85dc74ab3ff4be09b156d2e1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 09:30:04 -0400 Subject: [PATCH 4/9] feat(chat-ui): match iOS message card type, names and photos - Byline, footer and the "Public group" kind draw in white 60%; the header avatar grows to 24dp. - The author falls back from the roster to a profile the app already has, then a profile fetch, then "Someone". - A photo message draws a 56dp thumbnail beside its caption and no "Photo" label without one. A reader outside the chat's rules gets no blob, so the thumbnail draws its BlurHash only. --- .../core/src/main/res/values/strings.xml | 1 + .../internal/link/MessageLinkLookup.kt | 58 ++++++++++++-- .../internal/link/MessageLinkLookupTest.kt | 35 ++++++++ .../flipcash/shared/chat/models/LinkCard.kt | 21 ++++- .../shared/chat/ui/MessageLinkCard.kt | 79 ++++++++++++++++--- 5 files changed, 175 insertions(+), 19 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 4b2ca2145..e20db8f51 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1061,6 +1061,7 @@ Private group Direct message You + Someone %1$s · %2$s %1$s sent %1$s tipped diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt index 28fdfd9b7..25b6b563e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.link import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetMessageError @@ -9,6 +10,9 @@ import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.shared.chat.media.ChatPhoto +import com.flipcash.shared.chat.ui.media.photoBody import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent @@ -47,6 +51,7 @@ internal class MessageLinkLookup @Inject constructor( private val chatController: ChatController, private val messaging: ChatMessagingController, private val profiles: ProfileController, + private val knownProfiles: UserProfileDataSource, private val userManager: UserManager, private val groups: GroupLinkLookup, ) { @@ -68,7 +73,7 @@ internal class MessageLinkLookup @Inject constructor( val body = message.body() ?: return LinkCard.Message.State.Unresolved return LinkCard.Message.State.Resolved( source = source(chat), - message = excerpt(message, body, chat), + message = excerpt(message, body, chat, chat.chatId), reactionCount = message.reactions?.reactions?.sumOf { it.count } ?: 0, ) } @@ -87,7 +92,7 @@ internal class MessageLinkLookup @Inject constructor( val body = message.body() ?: return LinkCard.Message.State.Unresolved return LinkCard.Message.State.Gated( source = source(chat), - message = excerpt(message, body, chat = null), + message = excerpt(message, body, chat = null, chatId = chatId), requirement = groups.requirement(chat.rules), ) } @@ -118,6 +123,7 @@ internal class MessageLinkLookup @Inject constructor( message: ChatMessage, body: LinkCard.Message.Body, chat: ChatMetadata?, + chatId: ChatId, ): LinkCard.Message.Excerpt { val sender = message.senderId val isFromSelf = message.isFromSelf || (sender != null && sender == userManager.accountId) @@ -128,12 +134,47 @@ internal class MessageLinkLookup @Inject constructor( author = author, isFromSelf = isFromSelf, sentAt = message.timestamp, - body = if (message.redacted && body is LinkCard.Message.Body.Text) body.copy(redacted = true) else body, + body = body.forReader(message, chatId), ) } + /** + * Marks redacted text, and points a photo at its blob the way the transcript does (a sealed DM + * photo decrypts on load). A redacted message has no blob to point at, so it stays BlurHash only. + */ + private fun LinkCard.Message.Body.forReader(message: ChatMessage, chatId: ChatId): LinkCard.Message.Body = + when (this) { + is LinkCard.Message.Body.Text -> if (message.redacted) copy(redacted = true) else this + is LinkCard.Message.Body.Photo -> copy( + caption = caption?.let { if (message.redacted) it.copy(redacted = true) else it }, + photo = if (message.redacted) null else message.thumbnail()?.let { rendition -> + ChatPhoto( + chatId = chatId, + rendition = rendition, + senderId = message.senderId, + sealed = message.encryption != null, + ) + }, + ) + else -> this + } + + /** The smallest rendition that fills the card's thumbnail, as the transcript picks for a bubble. */ + private fun ChatMessage.thumbnail(): MediaItemRendition? = + content.firstNotNullOfOrNull { it.photoBody() } + ?.items?.firstOrNull() + ?.let { it.renditionForSize(THUMBNAIL_PX) ?: it.rendition() } + ?.takeIf { it.blobId.bytes.isNotEmpty() } + + /** + * A name the app already knows (any chat or saved profile) before a profile fetch, the order + * iOS uses, so a sender met elsewhere is named without a round-trip. + */ private suspend fun name(userId: ID): String? = - profiles.getProfileForUser(userId).getOrNull()?.displayLabel() + knownProfiles.getCachedProfile(userId)?.displayLabel() + ?: profiles.getProfileForUser(userId).getOrNull() + ?.also { knownProfiles.store(userId, it) } + ?.displayLabel() private fun UserProfile.displayLabel(): String? = nameOrHandle(displayName, handle) @@ -143,7 +184,11 @@ internal class MessageLinkLookup @Inject constructor( private fun MessageContent.body(): LinkCard.Message.Body? = when (this) { is MessageContent.Text -> LinkCard.Message.Body.Text(text) is MessageContent.Reply -> content.singleOrNull()?.body() - is MessageContent.Media -> caption?.let { LinkCard.Message.Body.Text(it.text) } ?: LinkCard.Message.Body.Other + is MessageContent.Media -> LinkCard.Message.Body.Photo( + caption = caption?.text?.takeIf { it.isNotEmpty() }?.let { LinkCard.Message.Body.Text(it) }, + photo = null, + blurHash = items.firstOrNull()?.renditions?.firstNotNullOfOrNull { it.blob?.image?.blurhash?.takeIf(String::isNotEmpty) }, + ) is MessageContent.Cash -> LinkCard.Message.Body.Cash( amount = amount.formatted(), tipped = action == MessageContent.Cash.Action.TIPPED, @@ -160,3 +205,6 @@ internal class MessageLinkLookup @Inject constructor( else -> false } } + +/** The card's 56dp thumbnail at up to 3x density. */ +private const val THUMBNAIL_PX = 168 diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt index a576621d7..cda8ab62c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.link +import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.controllers.ProfileController @@ -98,6 +99,7 @@ class MessageLinkLookupTest { val chatController: ChatController = mock(), val messaging: ChatMessagingController = mock(), val profiles: ProfileController = mock(), + val knownProfiles: UserProfileDataSource = mock(), val groups: GroupLinkLookup = mock(), ) @@ -106,6 +108,7 @@ class MessageLinkLookupTest { chatController = deps.chatController, messaging = deps.messaging, profiles = deps.profiles, + knownProfiles = deps.knownProfiles, userManager = mock { on { accountId } doReturn myId }, groups = deps.groups, ) @@ -162,6 +165,38 @@ class MessageLinkLookupTest { assertEquals("Ada", gated.message.author) } + @Test + fun `a sender outside the roster is named from a profile the app already has`() = runTest { + val deps = Deps(chats = stored(null, isMember = null)) + org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) + .thenReturn(Result.success(chat(members = emptyList()))) + org.mockito.kotlin.whenever(deps.messaging.getMessage(groupId, 42, ViewMode.FULL_OR_REDACTED)) + .thenReturn(Result.success(message())) + org.mockito.kotlin.whenever(deps.knownProfiles.getCachedProfile(senderId)).thenReturn(profile("Ted")) + + val state = lookup(deps)(card()).getOrThrow() + + assertEquals("Ted", assertIs(state).message.author) + org.mockito.kotlin.verify(deps.profiles, org.mockito.kotlin.never()).getProfileForUser(any()) + } + + @Test + fun `a redacted photo carries no blob, only its BlurHash`() = runTest { + val deps = Deps(chats = stored(null, isMember = null)) + org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) + .thenReturn(Result.success(chat())) + org.mockito.kotlin.whenever(deps.messaging.getMessage(groupId, 42, ViewMode.FULL_OR_REDACTED)) + .thenReturn(Result.success(message(content = MessageContent.Media(emptyList(), caption = null), redacted = true))) + org.mockito.kotlin.whenever(deps.profiles.getProfileForUser(any())) + .thenReturn(Result.success(profile("Ada"))) + + val state = lookup(deps)(card()).getOrThrow() + + val body = assertIs(assertIs(state).message.body) + assertEquals(null, body.photo) + assertEquals(null, body.caption) + } + @Test fun `a non-member of a private group sees the group's record and never the message`() = runTest { val deps = Deps(chats = stored(chat(isPrivate = true), isMember = false)) 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 8adceccae..5dd236b25 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 @@ -3,6 +3,7 @@ package com.flipcash.shared.chat.models import com.flipcash.services.models.UserProfile import com.flipcash.services.models.asHandle import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.chat.media.ChatPhoto import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem import com.getcode.opencode.model.core.ID @@ -348,8 +349,26 @@ sealed interface LinkCard { /** A payment: the amount, pre-formatted, and whether it was sent or tipped. */ data class Cash(val amount: String, val tipped: Boolean) : Body - /** A photo or anything else with no text to excerpt. */ + /** + * A photo, drawn as a thumbnail beside its [caption]. [photo] is null for a reader + * outside the chat's rules: the message then carries no blob, and only [blurHash] draws. + */ + data class Photo( + val caption: Text?, + val photo: ChatPhoto?, + val blurHash: String?, + ) : Body + + /** Anything else with no text to excerpt. */ data object Other : Body + + /** Whether the server withheld this message's content from the reader. */ + val withheld: Boolean + get() = when (this) { + is Text -> redacted + is Photo -> photo == null || caption?.redacted == true + else -> false + } } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt index 8f6e7dae9..1674bcc45 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.ui +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -24,6 +25,10 @@ import androidx.compose.ui.draw.blur import android.os.Build import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import com.flipcash.shared.chat.media.ChatPhoto +import com.flipcash.shared.common.ui.rememberBlurHashPainter import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -93,7 +98,7 @@ internal fun MessageLinkCard( is State.Gated -> { Header(state.source) Excerpt(state.message) - val redacted = (state.message.body as? Body.Text)?.redacted == true + val redacted = state.message.body.withheld if (redacted) state.requirement?.let { RequirementLine(it) } CardButton( text = stringResource(if (redacted) R.string.action_buy else R.string.action_join), @@ -108,7 +113,7 @@ internal fun MessageLinkCard( state.source.title ?: stringResource(R.string.label_linkCard_untitledGroup), ), style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, + color = MessageLinkCardDefaults.SECONDARY, ) val members = pluralStringResource( R.plurals.subtitle_chatMemberCount, @@ -120,7 +125,7 @@ internal fun MessageLinkCard( ?.let { stringResource(R.string.label_messageLink_membersHostedBy, members, it) } ?: members, style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + color = MessageLinkCardDefaults.SECONDARY, ) CardButton(text = stringResource(R.string.action_messageLink_request), onClick = onOpen) } @@ -132,7 +137,7 @@ internal fun MessageLinkCard( Text( text = stringResource(R.string.label_messageLink_lockedDirect), style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, + color = MessageLinkCardDefaults.SECONDARY, ) } } @@ -191,7 +196,7 @@ private fun HeaderRow(title: String, kind: String?, avatar: @Composable () -> Un Text( text = it, style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + color = MessageLinkCardDefaults.SECONDARY, maxLines = 1, ) } @@ -211,11 +216,11 @@ private fun PlaceholderAvatar() { private fun Excerpt(excerpt: LinkCard.Message.Excerpt) { val author = when { excerpt.isFromSelf -> stringResource(R.string.label_messageLink_you) - else -> excerpt.author + else -> excerpt.author ?: stringResource(R.string.label_messageLink_someone) } val body = excerpt.body val byline = when { - body is Body.Cash && author != null -> stringResource( + body is Body.Cash -> stringResource( if (body.tipped) R.string.label_messageLink_tipped else R.string.label_messageLink_sent, author, ) @@ -223,12 +228,17 @@ private fun Excerpt(excerpt: LinkCard.Message.Excerpt) { } val time = formatLastActivity(excerpt.sentAt) Text( - text = byline?.let { stringResource(R.string.label_messageLink_byline, it, time) } ?: time, + text = stringResource(R.string.label_messageLink_byline, byline, time), style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + color = MessageLinkCardDefaults.SECONDARY, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + ExcerptBody(body) +} + +@Composable +private fun ExcerptBody(body: Body) { when (body) { is Body.Text -> Text( modifier = Modifier.then( @@ -255,14 +265,52 @@ private fun Excerpt(excerpt: LinkCard.Message.Excerpt) { color = CodeTheme.colors.textMain, maxLines = 1, ) + // The thumbnail trails its caption; with no caption it stands alone, unlabelled. + is Body.Photo -> Row( + horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.GAP), + verticalAlignment = Alignment.Top, + ) { + Box(Modifier.weight(1f)) { body.caption?.let { ExcerptBody(it) } } + ChatPhotoThumbnail( + photo = body.photo, + blurHash = body.blurHash, + modifier = Modifier + .size(MessageLinkCardDefaults.THUMBNAIL) + .clip(RoundedCornerShape(MessageLinkCardDefaults.THUMBNAIL_RADIUS)), + ) + } Body.Other -> Text( text = stringResource(R.string.label_messageLink_attachment), style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textSecondary, + color = MessageLinkCardDefaults.SECONDARY, ) } } +/** BlurHash underneath, the photo over it once it loads; a null [photo] leaves only the BlurHash. */ +@Composable +private fun ChatPhotoThumbnail(photo: ChatPhoto?, blurHash: String?, modifier: Modifier = Modifier) { + val blur = rememberBlurHashPainter(blurHash) + Box(modifier.background(MessageLinkCardDefaults.BAR)) { + if (blur != null) { + Image( + painter = blur, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } + if (photo != null) { + AsyncImage( + model = photo, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } + } +} + @Composable private fun RequirementLine(requirement: LinkCard.GroupInvite.Requirement) { val amount = requirement.amount ?: return @@ -273,7 +321,7 @@ private fun RequirementLine(requirement: LinkCard.GroupInvite.Requirement) { stringResource(R.string.label_chat_balanceRequirement_anyToken, amount) }, style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + color = MessageLinkCardDefaults.SECONDARY, ) } @@ -287,7 +335,7 @@ private fun Footer(leading: String?, onOpen: (() -> Unit)?) { modifier = Modifier.weight(1f), text = leading.orEmpty(), style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + color = MessageLinkCardDefaults.SECONDARY, maxLines = 1, ) if (onOpen != null) { @@ -333,11 +381,16 @@ internal object MessageLinkCardDefaults { /** Skeleton bars and the placeholder avatar. */ val BAR = Color.White.copy(alpha = 0.12f) + /** Kind, byline, footer and dim lines: white at 60%, as iOS draws them. */ + val SECONDARY = Color.White.copy(alpha = 0.6f) + val SHAPE = RoundedCornerShape(12.dp) val PADDING = 12.dp val GAP = 6.dp val HEADER_GAP = 6.dp - val AVATAR = 20.dp + val AVATAR = 24.dp + val THUMBNAIL = 56.dp + val THUMBNAIL_RADIUS = 8.dp val BAR_HEIGHT = 10.dp val BUTTON_HEIGHT = 44.dp val REDACTED_BLUR = 6.dp From 78ae594528ae143a9c86ea4e646fdf0c72b3685d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 09:34:14 -0400 Subject: [PATCH 5/9] feat(chat-ui): show a linked cash link as its payment on the message card When the linked message is only a send.flipcash.com link, the card reads " sent a cash link", shows the amount in the cash card's large type and its claim state in the footer, matching iOS #1057. A cash link with other text around it, or a redacted message, stays an excerpt. --- .../core/src/main/res/values/strings.xml | 1 + .../internal/link/LinkCardClassifier.kt | 13 +++++ .../messenger/internal/link/LinkCardModule.kt | 2 + .../internal/link/LinkCardResolver.kt | 26 ++++++++- .../internal/link/LinkCardClassifierTest.kt | 11 ++++ .../internal/link/LinkCardResolverTest.kt | 54 +++++++++++++++++++ .../flipcash/shared/chat/models/LinkCard.kt | 6 +++ .../shared/chat/ui/MessageLinkCard.kt | 25 +++++++-- 8 files changed, 134 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e20db8f51..6a05599ec 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1065,6 +1065,7 @@ %1$s · %2$s %1$s sent %1$s tipped + %1$s sent a cash link Attachment %1$s reaction 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 4408b4831..8133b5d9a 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 @@ -91,6 +91,19 @@ internal class LinkCardClassifier @Inject constructor( return router.classify(DeepLink(target)) as? DeeplinkType.ChatMessage } + /** + * The entropy of [text] when the whole message is one cash link, or null. A message link's card + * draws such a message as the payment it carries rather than as a raw URL; a link with any + * other text around it is not one. + */ + fun wholeCashLink(text: String): String? { + val url = text.trim().takeIf { it.isNotEmpty() && it.none(Char::isWhitespace) } ?: return null + val target = unwrapJumpTarget(url) ?: url + val host = runCatching { target.toUri().host }.getOrNull() ?: return null + if (host.lowercase() !in CARD_HOSTS) return null + return (router.classify(DeepLink(target)) as? DeeplinkType.CashLink)?.entropy?.takeIf { it.isNotBlank() } + } + private fun classify(link: DetectedUrl): LinkCard? { val target = unwrapJumpTarget(link.url) ?: link.url val host = runCatching { target.toUri().host }.getOrNull() ?: return null 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 497beb9d2..0cf69f6d8 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 @@ -38,6 +38,7 @@ internal object LinkCardModule { web: WebLinkLookup, message: MessageLinkLookup, memory: LinkCardMemory, + classifier: LinkCardClassifier, ): LinkCardResolver = LinkCardResolver( // The resolver's own scope, ended by `ChatViewModel.onCleared`. A query outlives the @@ -51,5 +52,6 @@ internal object LinkCardModule { web = web::invoke, message = message::invoke, memory = memory, + wholeCashLink = classifier::wholeCashLink, ) } 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 d9cda2afc..531d8fdd2 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 @@ -58,6 +58,8 @@ internal class LinkCardResolver( Result.failure(IllegalStateException("no message lookup")) }, private val memory: LinkCardMemory = LinkCardMemory(), + /** The entropy when a message's whole text is one cash link; see [LinkCardClassifier.wholeCashLink]. */ + private val wholeCashLink: (text: String) -> String? = { null }, ) : LinkCardResolution { data class Snapshot( @@ -303,11 +305,33 @@ internal class LinkCardResolver( private suspend fun messageState(card: LinkCard.Message): LinkCard.Message.State = memoized(messageQueries, card.key) { message(card).fold( - onSuccess = { state -> state.also { messageAnswers[card.key] = it } }, + onSuccess = { state -> state.withCashLink().also { messageAnswers[card.key] = it } }, onFailure = { forget(messageQueries, card.key); LinkCard.Message.State.Unresolved }, ) } + /** + * Draws a linked message that is nothing but a cash link as that link's payment, through the + * same per-visit cash query a cash card in the transcript uses. A redacted body is a + * placeholder, so it is never read as a link; a link that does not resolve stays text. + */ + private suspend fun LinkCard.Message.State.withCashLink(): LinkCard.Message.State { + val excerpt = when (this) { + is LinkCard.Message.State.Resolved -> message + is LinkCard.Message.State.Gated -> message + else -> return this + } + val text = excerpt.body as? LinkCard.Message.Body.Text ?: return this + if (text.redacted) return this + val entropy = wholeCashLink(text.text) ?: return this + val cash = cashState(entropy) as? LinkCard.Cash.State.Resolved ?: return this + val body = excerpt.copy(body = LinkCard.Message.Body.CashLink(cash.amount, cash.claim)) + return when (this) { + is LinkCard.Message.State.Resolved -> copy(message = body) + is LinkCard.Message.State.Gated -> copy(message = body) + } + } + private data class MessageKey(val chatId: ChatId, val messageId: Long) private val LinkCard.Message.key get() = MessageKey(chatId, messageId) 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 dde8785d4..bb6a92390 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 @@ -193,6 +193,17 @@ class LinkCardClassifierTest { assertNull(classifier.firstCard(listOf(DetectedUrl(0, hostile.length, hostile)))) } + @Test + fun `a message that is only a cash link reads as that link`() { + val classifier = LinkCardClassifier(router) + val url = "https://send.flipcash.com/c/#/e=KNi8pQr1n5hRU65vKJGge3" + + assertEquals("KNi8pQr1n5hRU65vKJGge3", classifier.wholeCashLink(" $url\n")) + assertNull(classifier.wholeCashLink("for lunch $url")) + assertNull(classifier.wholeCashLink("https://send.flipcash.com.evil.com/c/#/e=KNi8pQr1n5hRU65vKJGge3")) + assertNull(classifier.wholeCashLink("https://app.flipcash.com/token/So11111111111111111111111111111111111111112")) + } + /** 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`() { 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 d6a52917f..486958a0a 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 @@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal.link import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.chat.models.LinkCard import com.getcode.opencode.model.financial.Token @@ -737,4 +738,57 @@ class LinkCardResolverTest { fun `a resolved web answer expires in memory at the resolved ttl and is looked up again`() = runTest { expiry(webResolved, WebLinks.RESOLVED_TTL) } + + private fun messageCard(text: String, redacted: Boolean = false) = LinkCard.Message( + url = "https://app.flipcash.com/chat/x/m/42", + start = 0, + end = 10, + chatId = ChatId(List(32) { it.toByte() }), + messageId = 42, + isGroup = false, + ) to LinkCard.Message.State.Resolved( + source = LinkCard.Message.Source( + title = "Ted", + kind = LinkCard.Message.Kind.DirectMessage, + picture = null, + pictureAccess = BlobAccessContext.profile(null), + ), + message = LinkCard.Message.Excerpt( + author = "Ted", + isFromSelf = false, + sentAt = kotlin.time.Instant.fromEpochSeconds(0), + body = LinkCard.Message.Body.Text(text, redacted), + ), + reactionCount = 0, + ) + + private fun cashLinkResolver(scope: kotlinx.coroutines.CoroutineScope, state: LinkCard.Message.State) = + LinkCardResolver( + scope = scope, + giftCard = { Result.success(snapshot()) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + message = { Result.success(state) }, + wholeCashLink = { it.takeIf { text -> text == card.url }?.let { card.entropy } }, + ) + + @Test + fun `a linked message that is only a cash link draws as its payment`() = runTest { + val (message, state) = messageCard(card.url) + val resolved = (cashLinkResolver(backgroundScope, state).resolve(message) as LinkCard.Message).state + + assertEquals( + LinkCard.Message.Body.CashLink("$15.00", LinkCard.Cash.Claim.Claimable), + (resolved as LinkCard.Message.State.Resolved).message.body, + ) + } + + @Test + fun `a redacted linked message is never read as a cash link`() = runTest { + val (message, state) = messageCard(card.url, redacted = true) + val resolved = (cashLinkResolver(backgroundScope, state).resolve(message) as LinkCard.Message).state + + assertEquals(state, resolved) + } } 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 5dd236b25..55777041b 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 @@ -349,6 +349,12 @@ sealed interface LinkCard { /** A payment: the amount, pre-formatted, and whether it was sent or tipped. */ data class Cash(val amount: String, val tipped: Boolean) : Body + /** + * A text message that is nothing but a cash link: the link's amount, pre-formatted, and + * its claim state, in place of the raw URL. A link with other text around it stays [Text]. + */ + data class CashLink(val amount: String, val claim: LinkCard.Cash.Claim) : Body + /** * A photo, drawn as a thumbnail beside its [caption]. [photo] is null for a reader * outside the chat's rules: the message then carries no blob, and only [blurHash] draws. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt index 1674bcc45..4ae581dbb 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt @@ -89,9 +89,11 @@ internal fun MessageLinkCard( Header(state.source) Excerpt(state.message) Footer( - leading = state.reactionCount.takeIf { it > 0 }?.let { - pluralStringResource(R.plurals.label_messageLink_reactions, it.toInt(), it.toString()) - }, + // A cash link's footer says where its claim stands, as its own card's stub does. + leading = (state.message.body as? Body.CashLink)?.let { claimCaption(it.claim) } + ?: state.reactionCount.takeIf { it > 0 }?.let { + pluralStringResource(R.plurals.label_messageLink_reactions, it.toInt(), it.toString()) + }, onOpen = onOpen, ) } @@ -224,6 +226,7 @@ private fun Excerpt(excerpt: LinkCard.Message.Excerpt) { if (body.tipped) R.string.label_messageLink_tipped else R.string.label_messageLink_sent, author, ) + body is Body.CashLink -> stringResource(R.string.label_messageLink_sentCashLink, author) else -> author } val time = formatLastActivity(excerpt.sentAt) @@ -265,6 +268,12 @@ private fun ExcerptBody(body: Body) { color = CodeTheme.colors.textMain, maxLines = 1, ) + is Body.CashLink -> Text( + text = body.amount, + style = CodeTheme.typography.displayExtraSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) // The thumbnail trails its caption; with no caption it stands alone, unlabelled. is Body.Photo -> Row( horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.GAP), @@ -325,6 +334,16 @@ private fun RequirementLine(requirement: LinkCard.GroupInvite.Requirement) { ) } +@Composable +private fun claimCaption(claim: LinkCard.Cash.Claim): String = stringResource( + when (claim) { + LinkCard.Cash.Claim.Claimable -> R.string.label_linkCard_claim + LinkCard.Cash.Claim.Claiming -> R.string.label_linkCard_claiming + LinkCard.Cash.Claim.Claimed -> R.string.label_linkCard_claimed + LinkCard.Cash.Claim.Expired -> R.string.label_linkCard_expired + } +) + @Composable private fun Footer(leading: String?, onOpen: (() -> Unit)?) { Row( From 2493f8262b3ea24fe7f6e82ef4fc75e5ae864dc3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 14:54:18 -0400 Subject: [PATCH 6/9] feat(chat-ui): match iOS's reworked message card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card now leads with one identity row: a 32dp avatar beside the chat name over "Author · time", with a chevron in place of the "Open" text when the card opens. The excerpt is 15sp medium at 88% white, up to four lines, with line breaks folded to spaces. A photo sits to the right of its caption, or alone at the start when it has none. The byline time follows iOS: now, 45m, 2h, Yesterday, Oct 9, then Oct 9, 2025 for earlier years. A link to a deleted message now names its chat and says "This message was deleted." instead of resting on the skeleton. --- .../core/src/main/res/values/strings.xml | 9 +- .../internal/link/MessageLinkLookup.kt | 4 + .../internal/link/MessageLinkLookupTest.kt | 5 +- .../flipcash/shared/chat/models/LinkCard.kt | 7 +- .../shared/chat/ui/MessageCardTime.kt | 52 ++ .../shared/chat/ui/MessageLinkCard.kt | 471 ++++++++++-------- .../shared/chat/ui/FlattenExcerptTest.kt | 17 + .../shared/chat/ui/MessageCardTimeTest.kt | 53 ++ .../chat/ui/MessageLinkCardScreenshotTest.kt | 19 +- 9 files changed, 419 insertions(+), 218 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageCardTime.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/FlattenExcerptTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageCardTimeTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 6a05599ec..eea1f81da 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1055,9 +1055,6 @@ Show preview · %1$s Group Unavailable - Flipcash - Message - Public group Private group Direct message You @@ -1072,9 +1069,13 @@ %1$s reactions Only members of %1$s can read this message. + This message was deleted. + Settled on Solana + now + %1$dm + %1$dh Only the two people in this chat can read it. %1$s · Hosted by %2$s - Open Request diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt index 25b6b563e..002dd73d9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt @@ -70,6 +70,7 @@ internal class MessageLinkLookup @Inject constructor( private suspend fun member(chat: ChatMetadata, messageId: Long): LinkCard.Message.State { val message = chats.readMessage(chat.chatId, messageId).getOrThrow() + if (message.isDeleted) return LinkCard.Message.State.Deleted(source(chat)) val body = message.body() ?: return LinkCard.Message.State.Unresolved return LinkCard.Message.State.Resolved( source = source(chat), @@ -89,6 +90,7 @@ internal class MessageLinkLookup @Inject constructor( ) } val message = messaging.getMessage(chatId, messageId, ViewMode.FULL_OR_REDACTED).getOrThrow() + if (message.isDeleted) return LinkCard.Message.State.Deleted(source(chat)) val body = message.body() ?: return LinkCard.Message.State.Unresolved return LinkCard.Message.State.Gated( source = source(chat), @@ -181,6 +183,8 @@ internal class MessageLinkLookup @Inject constructor( /** The card's body for this message, or null when there is nothing to show: deleted or sealed. */ private fun ChatMessage.body(): LinkCard.Message.Body? = content.singleOrNull()?.body() + private val ChatMessage.isDeleted: Boolean get() = content.singleOrNull() is MessageContent.Deleted + private fun MessageContent.body(): LinkCard.Message.Body? = when (this) { is MessageContent.Text -> LinkCard.Message.Body.Text(text) is MessageContent.Reply -> content.singleOrNull()?.body() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt index cda8ab62c..885f6cf0c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt @@ -258,12 +258,13 @@ class MessageLinkLookupTest { } @Test - fun `a deleted message is unresolved`() = runTest { + fun `a deleted message names its chat and nothing more`() = runTest { val deps = Deps(chats = stored(chat(), isMember = true)) org.mockito.kotlin.whenever(deps.chats.readMessage(groupId, 42)) .thenReturn(Result.success(message(content = MessageContent.Deleted(sentAt, senderId)))) - assertEquals(LinkCard.Message.State.Unresolved, lookup(deps)(card()).getOrThrow()) + val state = assertIs(lookup(deps)(card()).getOrThrow()) + assertEquals("Moony", state.source.title) } @Test 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 55777041b..9484ccba1 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 @@ -284,11 +284,14 @@ sealed interface LinkCard { data object Loading : State /** - * Nothing to show beyond the URL: the lookup failed, or the message is deleted or not - * there. Drawn as the skeleton at rest; resolution failing is never an error on a card. + * Nothing to show beyond the URL: the lookup failed, or the message is not there. Drawn + * as the skeleton at rest; resolution failing is never an error on a card. */ data object Unresolved : State + /** The message was deleted. Its chat still names itself; the message says only that. */ + data class Deleted(val source: Source) : State + /** The viewer is in the chat, or the chat is a public group whose rules let them read. */ data class Resolved( val source: Source, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageCardTime.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageCardTime.kt new file mode 100644 index 000000000..d35462a8a --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageCardTime.kt @@ -0,0 +1,52 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.getcode.util.formatLocalized +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Instant + +/** + * How long ago a linked message was sent, as the message card's byline says it: "now", "45m" and + * "2h" within the day, then "Yesterday", "Oct 9" within the year, and "Oct 9, 2025" before it. + * iOS draws the same (`LinkCardResolver.relativeTime`). + */ +internal sealed interface MessageCardTime { + data object Now : MessageCardTime + data class Minutes(val count: Long) : MessageCardTime + data class Hours(val count: Long) : MessageCardTime + data object Yesterday : MessageCardTime + data class Day(val date: LocalDate, val withYear: Boolean) : MessageCardTime + + companion object { + /** A [sentAt] after [now] counts as now; the card never says a message is from the future. */ + fun of(sentAt: Instant, now: Instant, timeZone: TimeZone): MessageCardTime { + val elapsed = (now - sentAt).coerceAtLeast(Duration.ZERO) + val sentDay = sentAt.toLocalDateTime(timeZone).date + val today = now.toLocalDateTime(timeZone).date + return when { + sentDay == today && elapsed.inWholeMinutes < 1 -> Now + sentDay == today && elapsed.inWholeHours < 1 -> Minutes(elapsed.inWholeMinutes) + sentDay == today -> Hours(elapsed.inWholeHours) + sentDay == today.minus(DatePeriod(days = 1)) -> Yesterday + else -> Day(sentDay, withYear = sentDay.year != today.year) + } + } + } +} + +@Composable +internal fun messageCardTime(sentAt: Instant): String = + when (val time = MessageCardTime.of(sentAt, Clock.System.now(), TimeZone.currentSystemDefault())) { + MessageCardTime.Now -> stringResource(R.string.label_messageLink_timeNow) + is MessageCardTime.Minutes -> stringResource(R.string.label_messageLink_timeMinutes, time.count) + is MessageCardTime.Hours -> stringResource(R.string.label_messageLink_timeHours, time.count) + MessageCardTime.Yesterday -> stringResource(R.string.label_chatReceipt_yesterday) + is MessageCardTime.Day -> sentAt.formatLocalized(if (time.withYear) "MMM d, yyyy" else "MMM d") + } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt index 4ae581dbb..084aa3f10 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt @@ -1,13 +1,14 @@ package com.flipcash.shared.chat.ui +import android.os.Build import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -16,36 +17,40 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur -import android.os.Build import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import coil3.compose.AsyncImage -import com.flipcash.shared.chat.media.ChatPhoto -import com.flipcash.shared.common.ui.rememberBlurHashPainter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight 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 androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage 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.ChatId +import com.flipcash.shared.chat.media.ChatPhoto import com.flipcash.shared.chat.models.LinkCard import com.flipcash.shared.chat.models.LinkCard.Message.Body import com.flipcash.shared.chat.models.LinkCard.Message.Kind import com.flipcash.shared.chat.models.LinkCard.Message.State import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.rememberBlurHashPainter import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton @@ -55,13 +60,15 @@ import kotlin.time.Duration.Companion.hours /** * A link to one message, drawn as a panel naming the chat it came from (node 11396:118). * - * Every state shares one frame: the chat's header, then whatever the viewer may see of the message, - * then the footer or the button. What fills it is decided upstream, by who is reading and where the - * message lives, never by the card; see [LinkCard.Message.State]. + * Every state shares one frame: an identity block (the chat's avatar beside its name over an + * "Author · time" line), then whatever the viewer may see of the message, then the footer or the + * button, 10 apart. What fills it is decided upstream, by who is reading and where the message + * lives, never by the card; see [LinkCard.Message.State]. The layout follows iOS's + * `LinkMessageCardContent` face for face, including the chevron in place of Figma's "Open". * * [onOpen] takes the card's tap and its button alike. Both follow the link, and the chat it lands * on runs its own gate, so Join, Buy and Request start the flows the group's screen already has - * rather than a second copy of them here. Null leaves the card inert. + * rather than a second copy of them here. Null leaves the card inert and drops the chevron. * * A redacted body is the server's placeholder, not the message. It is drawn blurred and kept out of * the semantics tree, so neither a screen reader nor a copy can lift it. @@ -74,73 +81,67 @@ internal fun MessageLinkCard( modifier: Modifier = Modifier, ) { val state = card.state - Column( + val opens = onOpen != null + BoxWithConstraints( modifier = modifier .fillMaxWidth() .clip(shape) .background(MessageLinkCardDefaults.FILL) .border(CodeTheme.dimens.border, MessageLinkCardDefaults.STROKE, shape) .padding(MessageLinkCardDefaults.PADDING), - verticalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.GAP), ) { - when (state) { - State.Loading, State.Unresolved -> Skeleton(card, shimmering = state == State.Loading) - is State.Resolved -> { - Header(state.source) - Excerpt(state.message) - Footer( - // A cash link's footer says where its claim stands, as its own card's stub does. - leading = (state.message.body as? Body.CashLink)?.let { claimCaption(it.claim) } - ?: state.reactionCount.takeIf { it > 0 }?.let { - pluralStringResource(R.plurals.label_messageLink_reactions, it.toInt(), it.toString()) - }, - onOpen = onOpen, - ) - } - is State.Gated -> { - Header(state.source) - Excerpt(state.message) - val redacted = state.message.body.withheld - if (redacted) state.requirement?.let { RequirementLine(it) } - CardButton( - text = stringResource(if (redacted) R.string.action_buy else R.string.action_join), - onClick = onOpen, - ) - } - is State.LockedGroup -> { - Header(state.source) - Text( - text = stringResource( - R.string.label_messageLink_lockedGroup, - state.source.title ?: stringResource(R.string.label_linkCard_untitledGroup), - ), - style = CodeTheme.typography.textMedium, - color = MessageLinkCardDefaults.SECONDARY, - ) - val members = pluralStringResource( - R.plurals.subtitle_chatMemberCount, - state.memberCount.toInt(), - state.memberCount.toString(), - ) - Text( - text = state.host - ?.let { stringResource(R.string.label_messageLink_membersHostedBy, members, it) } - ?: members, - style = CodeTheme.typography.textSmall, - color = MessageLinkCardDefaults.SECONDARY, - ) - CardButton(text = stringResource(R.string.action_messageLink_request), onClick = onOpen) - } - State.LockedDirect -> { - // Only that it is a DM. Who is in it is part of what stays locked. - HeaderRow(title = stringResource(R.string.label_messageLink_kindDirect), kind = null) { - PlaceholderAvatar() + val width = maxWidth + Column(verticalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.GAP)) { + when (state) { + State.Loading, State.Unresolved -> Skeleton(width, shimmering = state == State.Loading) + is State.Resolved -> { + val body = state.message.body + Identity(state.source, byline(state.message), opens) + Excerpt(body) + Footer( + when (body) { + // A cash link's footer says where its claim stands, as its own card's stub does. + is Body.CashLink -> claimCaption(body.claim) + is Body.Cash -> stringResource(R.string.label_messageLink_settled) + else -> state.reactionCount.takeIf { it > 0 }?.let { + pluralStringResource(R.plurals.label_messageLink_reactions, it.toInt(), it.toString()) + } + } + ) + } + is State.Gated -> { + val redacted = state.message.body.withheld + Identity(state.source, byline(state.message), opens) + Excerpt(state.message.body) + if (redacted) Footer(state.requirement?.let { requirementLine(it) }) + CardButton( + text = stringResource(if (redacted) R.string.action_buy else R.string.action_join), + onClick = onOpen, + ) + } + is State.LockedGroup -> { + val title = state.source.title ?: stringResource(R.string.label_linkCard_untitledGroup) + Identity(state.source, stringResource(R.string.label_messageLink_kindPrivateGroup), opens) + DimBody(stringResource(R.string.label_messageLink_lockedGroup, title)) + val members = pluralStringResource( + R.plurals.subtitle_chatMemberCount, + state.memberCount.toInt(), + state.memberCount.toString(), + ) + Footer(state.host?.let { stringResource(R.string.label_messageLink_membersHostedBy, members, it) } ?: members) + CardButton(text = stringResource(R.string.action_messageLink_request), onClick = onOpen) + } + State.LockedDirect -> { + // Only that it is a DM. Who is in it is part of what stays locked, and it never opens. + IdentityRow(name = stringResource(R.string.label_messageLink_kindDirect), meta = null, opens = false) { + PlaceholderAvatar() + } + DimBody(stringResource(R.string.label_messageLink_lockedDirect)) + } + is State.Deleted -> { + Identity(state.source, meta = null, opens = opens) + DimBody(stringResource(R.string.label_messageLink_deleted)) } - Text( - text = stringResource(R.string.label_messageLink_lockedDirect), - style = CodeTheme.typography.textMedium, - color = MessageLinkCardDefaults.SECONDARY, - ) } } } @@ -148,26 +149,56 @@ internal fun MessageLinkCard( /** * Loading and Unresolved: the frame with nothing in it, so nothing moves when the answer lands. The - * id's form says group or DM before any lookup, so the header can say which kind it is. + * identity bars are 45% and 25% of the card's width and the body bars 100% and 55%, as iOS draws them. */ @Composable -private fun Skeleton(card: LinkCard.Message, shimmering: Boolean) { - HeaderRow( - title = stringResource(R.string.label_messageLink_unresolvedTitle), - kind = stringResource(R.string.label_messageLink_message), - ) { PlaceholderAvatar() } +private fun Skeleton(width: Dp, shimmering: Boolean) { val bar = Modifier .height(MessageLinkCardDefaults.BAR_HEIGHT) .let { if (shimmering) it.shimmer(CircleShape) else it.background(MessageLinkCardDefaults.BAR, CircleShape) } - Spacer(Modifier.height(2.dp)) - Box(bar.fillMaxWidth()) - Box(bar.fillMaxWidth(0.6f)) + Row( + modifier = Modifier.clearAndSetSemantics {}, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.AVATAR_GAP), + ) { + PlaceholderAvatar() + Column(verticalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.SKELETON_IDENTITY_GAP)) { + Box(bar.width(width * MessageLinkCardDefaults.SKELETON_NAME_FRACTION)) + Box(bar.width(width * MessageLinkCardDefaults.SKELETON_META_FRACTION)) + } + } + Column( + modifier = Modifier.clearAndSetSemantics {}, + verticalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.GAP / 2), + ) { + Box(bar.fillMaxWidth()) + Box(bar.fillMaxWidth(MessageLinkCardDefaults.SKELETON_SHORT_FRACTION)) + } } +/** "Author · time", with the verb a payment adds to the author. */ @Composable -private fun Header(source: LinkCard.Message.Source) { +private fun byline(excerpt: LinkCard.Message.Excerpt): String { + val author = when { + excerpt.isFromSelf -> stringResource(R.string.label_messageLink_you) + else -> excerpt.author ?: stringResource(R.string.label_messageLink_someone) + } + val who = when (val body = excerpt.body) { + is Body.Cash -> stringResource( + if (body.tipped) R.string.label_messageLink_tipped else R.string.label_messageLink_sent, + author, + ) + is Body.CashLink -> stringResource(R.string.label_messageLink_sentCashLink, author) + else -> author + } + return stringResource(R.string.label_messageLink_byline, who, messageCardTime(excerpt.sentAt)) +} + +/** The identity block for a known chat: its picture, its name, and one meta line. */ +@Composable +private fun Identity(source: LinkCard.Message.Source, meta: String?, opens: Boolean) { val title = source.title ?: stringResource(R.string.label_linkCard_untitledGroup) - HeaderRow(title = title, kind = stringResource(source.kind.label())) { + IdentityRow(name = title, meta = meta, opens = opens) { ContactAvatar( image = source.picture, displayName = title, @@ -179,27 +210,41 @@ private fun Header(source: LinkCard.Message.Source) { } } +/** The avatar beside the name over the meta line, with a chevron when the card opens. */ @Composable -private fun HeaderRow(title: String, kind: String?, avatar: @Composable () -> Unit) { +private fun IdentityRow(name: String, meta: String?, opens: Boolean, avatar: @Composable () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.HEADER_GAP), + horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.AVATAR_GAP), ) { avatar() - Text( - modifier = Modifier.weight(1f, fill = false), - text = title, - style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.SemiBold), - color = CodeTheme.colors.textMain, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - kind?.let { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.IDENTITY_GAP), + ) { Text( - text = it, - style = CodeTheme.typography.textSmall, - color = MessageLinkCardDefaults.SECONDARY, + text = name, + style = MessageLinkCardDefaults.nameStyle, + color = CodeTheme.colors.textMain, maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + meta?.let { + Text( + text = it, + style = MessageLinkCardDefaults.metaStyle, + color = MessageLinkCardDefaults.SECONDARY, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (opens) { + Icon( + painter = painterResource(com.getcode.util.resources.R.drawable.ic_chevron_right), + contentDescription = null, + tint = MessageLinkCardDefaults.CHEVRON, + modifier = Modifier.size(MessageLinkCardDefaults.CHEVRON_WIDTH, MessageLinkCardDefaults.CHEVRON_HEIGHT), ) } } @@ -215,85 +260,80 @@ private fun PlaceholderAvatar() { } @Composable -private fun Excerpt(excerpt: LinkCard.Message.Excerpt) { - val author = when { - excerpt.isFromSelf -> stringResource(R.string.label_messageLink_you) - else -> excerpt.author ?: stringResource(R.string.label_messageLink_someone) - } - val body = excerpt.body - val byline = when { - body is Body.Cash -> stringResource( - if (body.tipped) R.string.label_messageLink_tipped else R.string.label_messageLink_sent, - author, - ) - body is Body.CashLink -> stringResource(R.string.label_messageLink_sentCashLink, author) - else -> author +private fun Excerpt(body: Body) { + when (body) { + is Body.Text -> ExcerptText(body) + is Body.Cash -> CashAmount(body.amount) + is Body.CashLink -> CashAmount(body.amount) + // The thumbnail trails its caption, top-aligned; with no caption it stands alone at the start. + is Body.Photo -> { + val thumbnail = Modifier + .size(MessageLinkCardDefaults.THUMBNAIL) + .clip(RoundedCornerShape(MessageLinkCardDefaults.THUMBNAIL_RADIUS)) + val caption = body.caption?.takeIf { it.text.isNotBlank() } + if (caption == null) { + ChatPhotoThumbnail(photo = body.photo, blurHash = body.blurHash, modifier = thumbnail) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.AVATAR_GAP), + verticalAlignment = Alignment.Top, + ) { + Box(Modifier.weight(1f)) { ExcerptText(caption) } + ChatPhotoThumbnail(photo = body.photo, blurHash = body.blurHash, modifier = thumbnail) + } + } + } + Body.Other -> DimBody(stringResource(R.string.label_messageLink_attachment)) } - val time = formatLastActivity(excerpt.sentAt) +} + +/** Up to four lines, with line breaks folded to spaces so blank lines don't spend them. */ +@Composable +private fun ExcerptText(body: Body.Text) { Text( - text = stringResource(R.string.label_messageLink_byline, byline, time), - style = CodeTheme.typography.textSmall, - color = MessageLinkCardDefaults.SECONDARY, - maxLines = 1, + modifier = Modifier.then( + when { + !body.redacted -> Modifier + canBlur -> Modifier + .blur(MessageLinkCardDefaults.REDACTED_BLUR, BlurredEdgeTreatment.Unbounded) + .clearAndSetSemantics {} + // `blur` draws nothing below Android 12; cover the text with a solid block instead. + else -> Modifier + .background(CodeTheme.colors.textSecondary.copy(alpha = 0.2f), RoundedCornerShape(4.dp)) + .clearAndSetSemantics {} + } + ), + text = flattenExcerpt(body.text), + style = MessageLinkCardDefaults.bodyStyle, + color = if (body.redacted && !canBlur) Color.Transparent else MessageLinkCardDefaults.EXCERPT, + maxLines = MessageLinkCardDefaults.BODY_LINES, overflow = TextOverflow.Ellipsis, ) - ExcerptBody(body) } +/** Each line break, and the whitespace around it, folded to one space. */ +internal fun flattenExcerpt(text: String): String = text.replace(LINE_BREAK, " ") + +private val LINE_BREAK = Regex("""\s*\n\s*""") + @Composable -private fun ExcerptBody(body: Body) { - when (body) { - is Body.Text -> Text( - modifier = Modifier.then( - when { - !body.redacted -> Modifier - canBlur -> Modifier - .blur(MessageLinkCardDefaults.REDACTED_BLUR, BlurredEdgeTreatment.Unbounded) - .clearAndSetSemantics {} - // `blur` draws nothing below Android 12; cover the text with a solid block instead. - else -> Modifier - .background(CodeTheme.colors.textSecondary.copy(alpha = 0.2f), RoundedCornerShape(4.dp)) - .clearAndSetSemantics {} - } - ), - text = body.text, - style = CodeTheme.typography.textMedium, - color = if (body.redacted && !canBlur) Color.Transparent else CodeTheme.colors.textMain, - maxLines = MessageLinkCardDefaults.BODY_LINES, - overflow = TextOverflow.Ellipsis, - ) - is Body.Cash -> Text( - text = body.amount, - style = CodeTheme.typography.displayExtraSmall, - color = CodeTheme.colors.textMain, - maxLines = 1, - ) - is Body.CashLink -> Text( - text = body.amount, - style = CodeTheme.typography.displayExtraSmall, - color = CodeTheme.colors.textMain, - maxLines = 1, - ) - // The thumbnail trails its caption; with no caption it stands alone, unlabelled. - is Body.Photo -> Row( - horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.GAP), - verticalAlignment = Alignment.Top, - ) { - Box(Modifier.weight(1f)) { body.caption?.let { ExcerptBody(it) } } - ChatPhotoThumbnail( - photo = body.photo, - blurHash = body.blurHash, - modifier = Modifier - .size(MessageLinkCardDefaults.THUMBNAIL) - .clip(RoundedCornerShape(MessageLinkCardDefaults.THUMBNAIL_RADIUS)), - ) - } - Body.Other -> Text( - text = stringResource(R.string.label_messageLink_attachment), - style = CodeTheme.typography.textMedium, - color = MessageLinkCardDefaults.SECONDARY, - ) - } +private fun CashAmount(amount: String) { + Text( + text = amount, + style = CodeTheme.typography.displayExtraSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + ) +} + +/** The panel's quieter body: locked and deleted copy, and a message with nothing to excerpt. */ +@Composable +private fun DimBody(text: String) { + Text( + text = text, + style = MessageLinkCardDefaults.bodyStyle, + color = MessageLinkCardDefaults.SECONDARY, + ) } /** BlurHash underneath, the photo over it once it loads; a null [photo] leaves only the BlurHash. */ @@ -321,17 +361,13 @@ private fun ChatPhotoThumbnail(photo: ChatPhoto?, blurHash: String?, modifier: M } @Composable -private fun RequirementLine(requirement: LinkCard.GroupInvite.Requirement) { - val amount = requirement.amount ?: return - Text( - 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 = MessageLinkCardDefaults.SECONDARY, - ) +private fun requirementLine(requirement: LinkCard.GroupInvite.Requirement): String? { + val amount = requirement.amount ?: return null + return if (requirement.currencyName != null) { + stringResource(R.string.label_chat_balanceRequirement, amount, requirement.currencyName) + } else { + stringResource(R.string.label_chat_balanceRequirement_anyToken, amount) + } } @Composable @@ -344,34 +380,24 @@ private fun claimCaption(claim: LinkCard.Cash.Claim): String = stringResource( } ) +/** One quiet line under the body; nothing at all when there is nothing to say. */ @Composable -private fun Footer(leading: String?, onOpen: (() -> Unit)?) { - Row( +private fun Footer(text: String?) { + if (text.isNullOrEmpty()) return + Text( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f), - text = leading.orEmpty(), - style = CodeTheme.typography.textSmall, - color = MessageLinkCardDefaults.SECONDARY, - maxLines = 1, - ) - if (onOpen != null) { - Text( - text = stringResource(R.string.action_messageLink_open), - style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.SemiBold), - color = CodeTheme.colors.textMain, - ) - } - } + text = text, + style = MessageLinkCardDefaults.metaStyle, + color = MessageLinkCardDefaults.SECONDARY, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } @Composable private fun CardButton(text: String, onClick: (() -> Unit)?) { CodeButton( modifier = Modifier - .padding(top = MessageLinkCardDefaults.GAP) .fillMaxWidth() .height(MessageLinkCardDefaults.BUTTON_HEIGHT), text = text, @@ -383,13 +409,7 @@ private fun CardButton(text: String, onClick: (() -> Unit)?) { ) } -private fun Kind.label(): Int = when (this) { - Kind.DirectMessage -> R.string.label_messageLink_kindDirect - Kind.PublicGroup -> R.string.label_messageLink_kindPublicGroup - Kind.PrivateGroup -> R.string.label_messageLink_kindPrivateGroup -} - -/** Node 11493:201, named after iOS's tokens there. */ +/** Node 11493:201, as iOS's `LinkMessageCardContent.Layout` carries it. */ internal object MessageLinkCardDefaults { /** Panel fill: white at 8%. */ val FILL = Color.White.copy(alpha = 0.08f) @@ -397,23 +417,51 @@ internal object MessageLinkCardDefaults { /** Panel stroke: white at 10%, the group card's. */ val STROKE = Color.White.copy(alpha = 0.1f) - /** Skeleton bars and the placeholder avatar. */ + /** Skeleton bars, the placeholder avatar and the thumbnail's ground. */ val BAR = Color.White.copy(alpha = 0.12f) - /** Kind, byline, footer and dim lines: white at 60%, as iOS draws them. */ + /** The excerpt: white at 88%. */ + val EXCERPT = Color.White.copy(alpha = 0.88f) + + /** Meta, footer and dim body lines: white at 60%. */ val SECONDARY = Color.White.copy(alpha = 0.6f) + val CHEVRON = Color.White.copy(alpha = 0.42f) + val SHAPE = RoundedCornerShape(12.dp) - val PADDING = 12.dp - val GAP = 6.dp - val HEADER_GAP = 6.dp - val AVATAR = 24.dp + val PADDING = androidx.compose.foundation.layout.PaddingValues(start = 14.dp, top = 12.dp, end = 14.dp, bottom = 13.dp) + + /** Between the card's groups: identity, body, footer, button. */ + val GAP = 10.dp + val AVATAR = 32.dp + val AVATAR_GAP = 10.dp + + /** Between the identity block's name and meta lines. */ + val IDENTITY_GAP = 2.dp + val SKELETON_IDENTITY_GAP = 6.dp + const val SKELETON_NAME_FRACTION = 0.45f + const val SKELETON_META_FRACTION = 0.25f + const val SKELETON_SHORT_FRACTION = 0.55f val THUMBNAIL = 56.dp val THUMBNAIL_RADIUS = 8.dp val BAR_HEIGHT = 10.dp val BUTTON_HEIGHT = 44.dp val REDACTED_BLUR = 6.dp + + /** SF's chevron.right at 13 semibold is about 7 by 12. */ + val CHEVRON_WIDTH = 7.dp + val CHEVRON_HEIGHT = 12.dp const val BODY_LINES = 4 + + val nameStyle: TextStyle + @Composable get() = CodeTheme.typography.textSmall.copy(fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold) + + val metaStyle: TextStyle + @Composable get() = CodeTheme.typography.textSmall.copy(fontSize = 13.sp, lineHeight = 17.sp, fontWeight = FontWeight.Medium) + + /** 15 medium; iOS adds 3 of line spacing to the font's own leading. */ + val bodyStyle: TextStyle + @Composable get() = CodeTheme.typography.textSmall.copy(fontSize = 15.sp, lineHeight = 21.sp, fontWeight = FontWeight.Medium) } // region Previews @@ -504,6 +552,11 @@ private fun Preview_MessageLink_Cash() = PreviewMessageCard( @Composable private fun Preview_MessageLink_LockedDirect() = PreviewMessageCard(State.LockedDirect) +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_MessageLink_Deleted() = PreviewMessageCard(State.Deleted(previewSource)) + @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/FlattenExcerptTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/FlattenExcerptTest.kt new file mode 100644 index 000000000..8fd586f3b --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/FlattenExcerptTest.kt @@ -0,0 +1,17 @@ +package com.flipcash.shared.chat.ui + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FlattenExcerptTest { + + @Test + fun `line breaks and the space around them fold to one space`() { + assertEquals("v2.4 is out - Message links - Fixes", flattenExcerpt("v2.4 is out\n\n- Message links \n - Fixes")) + } + + @Test + fun `text without line breaks is unchanged`() { + assertEquals("one line", flattenExcerpt("one line")) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageCardTimeTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageCardTimeTest.kt new file mode 100644 index 000000000..a4c1071d7 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageCardTimeTest.kt @@ -0,0 +1,53 @@ +package com.flipcash.shared.chat.ui + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** The cases iOS's `MessageCardTimeTests` holds its byline to. */ +class MessageCardTimeTest { + + private val utc = TimeZone.UTC + private val now = LocalDateTime(2026, 10, 16, 15, 0).toInstant(utc) + + private fun time(sentAt: kotlin.time.Instant) = MessageCardTime.of(sentAt, now, utc) + + @Test + fun `under a minute is now`() { + assertEquals(MessageCardTime.Now, time(now - 20.seconds)) + } + + @Test + fun `the same day counts minutes then hours`() { + assertEquals(MessageCardTime.Minutes(45), time(now - 45.minutes)) + assertEquals(MessageCardTime.Hours(2), time(now - 2.hours - 59.seconds)) + } + + @Test + fun `the day before is yesterday`() { + assertEquals(MessageCardTime.Yesterday, time(now - 20.hours)) + } + + @Test + fun `this year omits the year`() { + val sentAt = LocalDateTime(2026, 10, 9, 9, 0).toInstant(utc) + assertEquals(MessageCardTime.Day(LocalDate(2026, 10, 9), withYear = false), time(sentAt)) + } + + @Test + fun `an earlier year keeps the year`() { + val sentAt = LocalDateTime(2025, 10, 9, 9, 0).toInstant(utc) + assertEquals(MessageCardTime.Day(LocalDate(2025, 10, 9), withYear = true), time(sentAt)) + } + + @Test + fun `a future time is now`() { + assertEquals(MessageCardTime.Now, time(now + 5.minutes)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt index 01d9dbeb4..caa95d83b 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt @@ -39,7 +39,7 @@ import kotlin.time.Instant */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(qualifiers = "w402dp-h2200dp-xhdpi") +@Config(qualifiers = "w402dp-h3200dp-xhdpi") class MessageLinkCardScreenshotTest { @get:Rule @@ -61,7 +61,24 @@ class MessageLinkCardScreenshotTest { State.Loading, State.Unresolved, State.Resolved(source(Kind.PublicGroup), excerpt(text), reactionCount = 12), + State.Resolved( + source(Kind.PublicGroup, "Release Channel"), + excerpt(Body.Text("v2.4 is out\n\n- Message links\n- Faster sync\n- Fixes")), + reactionCount = 0, + ), + State.Resolved( + source(Kind.PublicGroup), + excerpt(Body.Photo(caption = Body.Text("Sunset from the roof"), photo = null, blurHash = null)), + reactionCount = 0, + ), + State.Resolved(source(Kind.PublicGroup), excerpt(Body.Photo(caption = null, photo = null, blurHash = null)), reactionCount = 0), State.Resolved(source(Kind.DirectMessage, "Ada"), excerpt(Body.Cash("$5.00", tipped = true)), reactionCount = 0), + State.Resolved( + source(Kind.DirectMessage, "Ada"), + excerpt(Body.CashLink("$20.00", LinkCard.Cash.Claim.Claimed)), + reactionCount = 0, + ), + State.Deleted(source(Kind.PublicGroup)), State.Gated(source(Kind.PublicGroup), excerpt(text), requirement = null), State.Gated( source(Kind.PublicGroup), From b4f093514277a73a9bd750b23ed0ba2acfa0b9ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 15:03:54 -0400 Subject: [PATCH 7/9] fix(messenger): show a link to a message that's gone as deleted A message GetMessage reports NotFound in a chat it does have drew the loading skeleton forever. iOS draws the deleted card for a nil message (MessageLinkLookup.swift), so the lookup now does the same, and a message carrying any Deleted content counts, not only a lone one. --- .../messenger/internal/link/MessageLinkLookup.kt | 14 +++++++++++--- .../internal/link/MessageLinkLookupTest.kt | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt index 002dd73d9..9b383e89b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt @@ -69,7 +69,8 @@ internal class MessageLinkLookup @Inject constructor( } private suspend fun member(chat: ChatMetadata, messageId: Long): LinkCard.Message.State { - val message = chats.readMessage(chat.chatId, messageId).getOrThrow() + val message = chats.readMessage(chat.chatId, messageId).getOrNullWhenMissing() + ?: return LinkCard.Message.State.Deleted(source(chat)) if (message.isDeleted) return LinkCard.Message.State.Deleted(source(chat)) val body = message.body() ?: return LinkCard.Message.State.Unresolved return LinkCard.Message.State.Resolved( @@ -89,7 +90,8 @@ internal class MessageLinkLookup @Inject constructor( host = chat.creator?.let { name(it) }, ) } - val message = messaging.getMessage(chatId, messageId, ViewMode.FULL_OR_REDACTED).getOrThrow() + val message = messaging.getMessage(chatId, messageId, ViewMode.FULL_OR_REDACTED).getOrNullWhenMissing() + ?: return LinkCard.Message.State.Deleted(source(chat)) if (message.isDeleted) return LinkCard.Message.State.Deleted(source(chat)) val body = message.body() ?: return LinkCard.Message.State.Unresolved return LinkCard.Message.State.Gated( @@ -183,7 +185,13 @@ internal class MessageLinkLookup @Inject constructor( /** The card's body for this message, or null when there is nothing to show: deleted or sealed. */ private fun ChatMessage.body(): LinkCard.Message.Body? = content.singleOrNull()?.body() - private val ChatMessage.isDeleted: Boolean get() = content.singleOrNull() is MessageContent.Deleted + /** + * The message, or null when the server says it isn't there. Its chat is, so the card names the + * chat and calls the message deleted, as iOS does. + */ + private fun Result.getOrNullWhenMissing(): T? = getOrElse { if (it is GetMessageError.NotFound) null else throw it } + + private val ChatMessage.isDeleted: Boolean get() = content.any { it is MessageContent.Deleted } private fun MessageContent.body(): LinkCard.Message.Body? = when (this) { is MessageContent.Text -> LinkCard.Message.Body.Text(text) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt index 885f6cf0c..57d2ef0d2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt @@ -268,14 +268,24 @@ class MessageLinkLookupTest { } @Test - fun `a message the server doesn't have is unresolved`() = runTest { + fun `a message the server doesn't have, in a chat it does, is deleted`() = runTest { val deps = Deps(chats = stored(null, isMember = null)) org.mockito.kotlin.whenever(deps.chatController.getChat(groupId, ViewMode.REDACTED)) .thenReturn(Result.success(chat())) org.mockito.kotlin.whenever(deps.messaging.getMessage(groupId, 42, ViewMode.FULL_OR_REDACTED)) .thenReturn(Result.failure(GetMessageError.NotFound())) - assertEquals(LinkCard.Message.State.Unresolved, lookup(deps)(card()).getOrThrow()) + val state = assertIs(lookup(deps)(card()).getOrThrow()) + assertEquals("Moony", state.source.title) + } + + @Test + fun `a member's message that isn't stored is deleted`() = runTest { + val deps = Deps(chats = stored(chat(), isMember = true)) + org.mockito.kotlin.whenever(deps.chats.readMessage(groupId, 42)) + .thenReturn(Result.failure(GetMessageError.NotFound())) + + assertIs(lookup(deps)(card()).getOrThrow()) } @Test From 17f8b923d8324421b354301eed8fd0e2ce97fe71 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 15:29:44 -0400 Subject: [PATCH 8/9] fix(messenger): load a message card's photo when its rendition has no metadata A photo rendition can arrive with its blob id but no blob metadata, as on the Test B DM photo in XCTests. Every rendition picker skips those, so the card had no photo and no BlurHash and drew an empty square. Fall back to the first rendition, as the transcript bubble does; the loader fetches it by id. --- .../internal/link/MessageLinkLookup.kt | 8 ++++++-- .../internal/link/MessageLinkLookupTest.kt | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt index 9b383e89b..f5b1ed643 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt @@ -163,11 +163,15 @@ internal class MessageLinkLookup @Inject constructor( else -> this } - /** The smallest rendition that fills the card's thumbnail, as the transcript picks for a bubble. */ + /** + * The smallest rendition that fills the card's thumbnail, as the transcript picks for a bubble. + * A rendition the server sent without its blob metadata is still loadable by id, so the first + * one stands in when none carries any, as it does in the transcript. + */ private fun ChatMessage.thumbnail(): MediaItemRendition? = content.firstNotNullOfOrNull { it.photoBody() } ?.items?.firstOrNull() - ?.let { it.renditionForSize(THUMBNAIL_PX) ?: it.rendition() } + ?.let { it.renditionForSize(THUMBNAIL_PX) ?: it.rendition() ?: it.renditions.firstOrNull() } ?.takeIf { it.blobId.bytes.isNotEmpty() } /** diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt index 57d2ef0d2..9702f665f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt @@ -7,12 +7,15 @@ import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetMessageError import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EmojiReaction +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ViewMode @@ -197,6 +200,21 @@ class MessageLinkLookupTest { assertEquals(null, body.caption) } + @Test + fun `a photo whose rendition came without metadata still loads by its blob id`() = runTest { + val rendition = MediaItemRendition(MediaItemRendition.Role.ORIGINAL, BlobId(byteArrayOf(9, 8, 7)), blob = null) + val photo = MessageContent.Media(listOf(MediaItem(listOf(rendition))), caption = MessageContent.Text("With words")) + val chats = stored(chat(), isMember = true) + org.mockito.kotlin.whenever(chats.readMessage(groupId, 42)).thenReturn( + Result.success(message(content = MessageContent.Reply(repliedMessageId = 41, content = listOf(photo)))), + ) + + val state = lookup(Deps(chats = chats))(card()).getOrThrow() + + val body = assertIs(assertIs(state).message.body) + assertEquals(rendition, body.photo?.rendition) + } + @Test fun `a non-member of a private group sees the group's record and never the message`() = runTest { val deps = Deps(chats = stored(chat(isPrivate = true), isMember = false)) From 1d99776b52717c6d026df202515450a68dc6e3e6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 17:06:41 -0400 Subject: [PATCH 9/9] fix(messenger): show a message this version can't read as an attachment on its card code/cash added MessageContent.Unsupported (#1770), which left the card's body mapping non-exhaustive. The card has no way to draw it, so it falls back to the attachment line, like a widget or system message. --- .../flipcash/app/messenger/internal/link/MessageLinkLookup.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt index f5b1ed643..9cc465fd3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt @@ -209,7 +209,7 @@ internal class MessageLinkLookup @Inject constructor( amount = amount.formatted(), tipped = action == MessageContent.Cash.Action.TIPPED, ) - is MessageContent.Widget, is MessageContent.System -> LinkCard.Message.Body.Other + is MessageContent.Widget, is MessageContent.System, MessageContent.Unsupported -> LinkCard.Message.Body.Other is MessageContent.Deleted, is MessageContent.Encrypted -> null }