diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index b904d2bc34..eea1f81da7 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -1055,6 +1055,28 @@
Show preview · %1$s
Group Unavailable
+ Private group
+ Direct message
+ You
+ Someone
+ %1$s · %2$s
+ %1$s sent
+ %1$s tipped
+ %1$s sent a cash link
+ Attachment
+
+ - %1$s reaction
+ - %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
+ 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 6bbd0af739..c5af7a01e7 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 526d298801..8133b5d9ac 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
@@ -147,6 +160,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 20bcf4f945..0cf69f6d8b 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,7 +36,9 @@ internal object LinkCardModule {
group: GroupLinkLookup,
user: UserLinkLookup,
web: WebLinkLookup,
+ message: MessageLinkLookup,
memory: LinkCardMemory,
+ classifier: LinkCardClassifier,
): LinkCardResolver =
LinkCardResolver(
// The resolver's own scope, ended by `ChatViewModel.onCleared`. A query outlives the
@@ -48,6 +50,8 @@ internal object LinkCardModule {
group = { group(it) },
user = { user(it) },
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 f0d87a0a89..531d8fdd26 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,7 +54,12 @@ 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(),
+ /** 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(
@@ -75,6 +80,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 +100,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 +134,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 +143,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 +302,40 @@ 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.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)
+
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 0000000000..9cc465fd3c
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookup.kt
@@ -0,0 +1,226 @@
+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
+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
+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 knownProfiles: UserProfileDataSource,
+ 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).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(
+ source = source(chat),
+ message = excerpt(message, body, chat, chat.chatId),
+ 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).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(
+ source = source(chat),
+ message = excerpt(message, body, chat = null, chatId = chatId),
+ 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?,
+ chatId: ChatId,
+ ): 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 = 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.
+ * 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() ?: it.renditions.firstOrNull() }
+ ?.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? =
+ knownProfiles.getCachedProfile(userId)?.displayLabel()
+ ?: profiles.getProfileForUser(userId).getOrNull()
+ ?.also { knownProfiles.store(userId, it) }
+ ?.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()
+
+ /**
+ * 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)
+ is MessageContent.Reply -> content.singleOrNull()?.body()
+ 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,
+ )
+ is MessageContent.Widget, is MessageContent.System, MessageContent.Unsupported -> 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
+ }
+}
+
+/** The card's 56dp thumbnail at up to 3x density. */
+private const val THUMBNAIL_PX = 168
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 82c16f7283..489c31242b 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 c45475aa69..bb6a923906 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")!!
@@ -180,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`() {
@@ -208,6 +232,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/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt
index d6a52917f0..486958a0a3 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/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 0000000000..9702f665fb
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkLookupTest.kt
@@ -0,0 +1,344 @@
+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
+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
+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 knownProfiles: UserProfileDataSource = mock(),
+ val groups: GroupLinkLookup = mock(),
+ )
+
+ private fun lookup(deps: Deps) = MessageLinkLookup(
+ chats = deps.chats,
+ chatController = deps.chatController,
+ messaging = deps.messaging,
+ profiles = deps.profiles,
+ knownProfiles = deps.knownProfiles,
+ 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 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 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))
+ 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 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))))
+
+ val state = assertIs(lookup(deps)(card()).getOrThrow())
+ assertEquals("Moony", state.source.title)
+ }
+
+ @Test
+ 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()))
+
+ 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
+ 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 ee4ac6bcbe..9484ccba12 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,8 @@ 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
@@ -258,4 +260,124 @@ 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 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,
+ 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 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.
+ */
+ 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/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt
index 76749cb92c..1505dc18af 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 8d4e3013b4..8485230afe 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
@@ -146,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.
@@ -178,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 },
@@ -911,8 +915,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/MessageCardTime.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageCardTime.kt
new file mode 100644
index 0000000000..d35462a8a3
--- /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
new file mode 100644
index 0000000000..084aa3f10b
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt
@@ -0,0 +1,568 @@
+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.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.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 androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+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
+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: 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 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.
+ */
+@Composable
+internal fun MessageLinkCard(
+ card: LinkCard.Message,
+ shape: CornerBasedShape,
+ onOpen: (() -> Unit)?,
+ modifier: Modifier = Modifier,
+) {
+ val state = card.state
+ val opens = onOpen != null
+ BoxWithConstraints(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .background(MessageLinkCardDefaults.FILL)
+ .border(CodeTheme.dimens.border, MessageLinkCardDefaults.STROKE, shape)
+ .padding(MessageLinkCardDefaults.PADDING),
+ ) {
+ 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))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Loading and Unresolved: the frame with nothing in it, so nothing moves when the answer lands. The
+ * 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(width: Dp, shimmering: Boolean) {
+ val bar = Modifier
+ .height(MessageLinkCardDefaults.BAR_HEIGHT)
+ .let { if (shimmering) it.shimmer(CircleShape) else it.background(MessageLinkCardDefaults.BAR, CircleShape) }
+ 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 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)
+ IdentityRow(name = title, meta = meta, opens = opens) {
+ ContactAvatar(
+ image = source.picture,
+ displayName = title,
+ access = source.pictureAccess,
+ modifier = Modifier
+ .size(MessageLinkCardDefaults.AVATAR)
+ .clip(CircleShape),
+ )
+ }
+}
+
+/** The avatar beside the name over the meta line, with a chevron when the card opens. */
+@Composable
+private fun IdentityRow(name: String, meta: String?, opens: Boolean, avatar: @Composable () -> Unit) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.AVATAR_GAP),
+ ) {
+ avatar()
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(MessageLinkCardDefaults.IDENTITY_GAP),
+ ) {
+ Text(
+ 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),
+ )
+ }
+ }
+}
+
+@Composable
+private fun PlaceholderAvatar() {
+ Box(
+ Modifier
+ .size(MessageLinkCardDefaults.AVATAR)
+ .background(MessageLinkCardDefaults.BAR, CircleShape),
+ )
+}
+
+@Composable
+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))
+ }
+}
+
+/** 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(
+ 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,
+ )
+}
+
+/** 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 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. */
+@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): 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
+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
+ }
+)
+
+/** One quiet line under the body; nothing at all when there is nothing to say. */
+@Composable
+private fun Footer(text: String?) {
+ if (text.isNullOrEmpty()) return
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = text,
+ style = MessageLinkCardDefaults.metaStyle,
+ color = MessageLinkCardDefaults.SECONDARY,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+}
+
+@Composable
+private fun CardButton(text: String, onClick: (() -> Unit)?) {
+ CodeButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(MessageLinkCardDefaults.BUTTON_HEIGHT),
+ text = text,
+ buttonState = ButtonState.Filled,
+ overrideContentPadding = true,
+ contentPadding = GroupInviteCardDefaults.NO_PADDING,
+ enabled = onClick != null,
+ onClick = { onClick?.invoke() },
+ )
+}
+
+/** 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)
+
+ /** Panel stroke: white at 10%, the group card's. */
+ val STROKE = Color.White.copy(alpha = 0.1f)
+
+ /** Skeleton bars, the placeholder avatar and the thumbnail's ground. */
+ val BAR = Color.White.copy(alpha = 0.12f)
+
+ /** 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 = 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
+
+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_Deleted() = PreviewMessageCard(State.Deleted(previewSource))
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+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/FlattenExcerptTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/FlattenExcerptTest.kt
new file mode 100644
index 0000000000..8fd586f3b1
--- /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 0000000000..a4c1071d74
--- /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
new file mode 100644
index 0000000000..caa95d83bb
--- /dev/null
+++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MessageLinkCardScreenshotTest.kt
@@ -0,0 +1,135 @@
+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-h3200dp-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.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),
+ 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})")
+ }
+}
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 da89a5a35e..9af84c0cbf 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 b1f93e7b0c..22ff62f3d8 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