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