From f0194e4aff0a37565f615dbce7d1b3c8dfaf49dd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 17:00:14 -0400 Subject: [PATCH 01/10] feat(services): carry a sender-built link preview on text messages --- .../converters/ChatTypeConverters.kt | 7 ++++++- .../converters/ChatTypeConvertersTest.kt | 19 +++++++++++++++++ .../sources/mapper/chat/ChatEntityMapper.kt | 4 ++-- .../services/chat/ChatContentCrypto.kt | 11 ++++++---- .../com/flipcash/services/chat/SealedMedia.kt | 21 +++++++++++++++++-- .../network/extensions/LocalToProtobuf.kt | 13 +++++++++++- .../network/extensions/ProtobufToLocal.kt | 14 ++++++++++++- .../services/models/chat/LinkPreview.kt | 17 +++++++++++++++ .../services/models/chat/MessageContent.kt | 7 ++++++- 9 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/LinkPreview.kt diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index c67b89a81..ed8b41cfc 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -3,6 +3,7 @@ package com.flipcash.app.persistence.converters import androidx.room.TypeConverter import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.LinkPreview import com.flipcash.services.models.chat.MediaItem import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName @@ -190,7 +191,11 @@ class ChatTypeConverters { sealed interface MessageContentSerialized { @Serializable @SerialName("text") - data class Text(val text: String) : MessageContentSerialized + // [linkPreview] defaults to null so rows stored before previews existed still decode. + data class Text( + val text: String, + val linkPreview: LinkPreview? = null, + ) : MessageContentSerialized @Serializable @SerialName("cash") diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt index a60e8df85..efae576f3 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/converters/ChatTypeConvertersTest.kt @@ -2,6 +2,7 @@ package com.flipcash.app.persistence.converters import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.LinkPreview import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -84,6 +85,24 @@ class ChatTypeConvertersTest { assertEquals(original, deserialized) } + @Test + fun `text row stored before link previews existed still decodes`() { + val old = """[{"type":"text","text":"hello"}]""" + assertEquals(listOf(MessageContentSerialized.Text("hello")), converter.fromMessageContentList(old)) + } + + @Test + fun `text with a link preview round-trips`() { + val original = listOf( + MessageContentSerialized.Text( + "see https://example.com", + LinkPreview(url = "https://example.com", title = "Example", description = "A page"), + ), + ) + val deserialized = converter.fromMessageContentList(converter.toMessageContentList(original)) + assertEquals(original, deserialized) + } + @Test fun `fromMessageContentList and toMessageContentList roundtrip unsupported content`() { val original = listOf(MessageContentSerialized.Unsupported) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index e738bc451..9dfe768b2 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -390,7 +390,7 @@ class ChatEntityMapper @Inject constructor() { // region Serialization helpers private fun MessageContent.toSerialized(): MessageContentSerialized = when (this) { - is MessageContent.Text -> MessageContentSerialized.Text(text) + is MessageContent.Text -> MessageContentSerialized.Text(text, linkPreview) is MessageContent.Cash -> MessageContentSerialized.Cash( intentId = intentId.base58, quarks = amount.quarks, @@ -428,7 +428,7 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { is MessageContentSerialized.WidgetShareProfile -> MessageContent.Widget( username?.let { WidgetContent.ShareProfile(it) } ?: WidgetContent.Unsupported, ) - is MessageContentSerialized.Text -> MessageContent.Text(text) + is MessageContentSerialized.Text -> MessageContent.Text(text, linkPreview) is MessageContentSerialized.Cash -> MessageContent.Cash( intentId = intentId.decodeBase58().toList(), amount = Fiat( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt index 7cf4b6ccb..7df50daf5 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt @@ -2,6 +2,7 @@ package com.flipcash.services.chat import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel import com.flipcash.services.internal.network.extensions.asContent +import com.flipcash.services.internal.network.extensions.asMediaItem import com.flipcash.services.internal.network.extensions.toMessageContent import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MessageContent @@ -208,10 +209,10 @@ sealed interface OpenedBlob { } internal fun MessageContent.isSealable(): Boolean = when (this) { - is MessageContent.Text -> true + is MessageContent.Text -> linkPreview?.image?.let { SealedMediaContract.isValid(it.asMediaItem()) } != false is MessageContent.Media -> SealedMediaContract.isValid(asContent().media) is MessageContent.Reply -> content.isNotEmpty() && - (content.all { it is MessageContent.Text } || content.singleOrNull()?.let { + (content.all { it is MessageContent.Text && it.isSealable() } || content.singleOrNull()?.let { it is MessageContent.Media && it.isSealable() } == true) else -> false @@ -222,11 +223,13 @@ internal fun MessageContent.isSealable(): Boolean = when (this) { * anything else takes the same "update" path as a type this client has never heard of. */ internal fun MessagingModel.Content.isRenderable(): Boolean = when (typeCase) { - MessagingModel.Content.TypeCase.TEXT -> true + // A preview whose image metadata breaks the sealed-photo rules makes the message unsupported. + MessagingModel.Content.TypeCase.TEXT -> + text.linkPreviewsList.all { !it.hasImage() || SealedMediaContract.isValid(it.image) } MessagingModel.Content.TypeCase.MEDIA -> SealedMediaContract.isValid(media) MessagingModel.Content.TypeCase.REPLY -> reply.contentCount > 0 && - (reply.contentList.all { it.typeCase == MessagingModel.Content.TypeCase.TEXT } || + (reply.contentList.all { it.typeCase == MessagingModel.Content.TypeCase.TEXT && it.isRenderable() } || (reply.contentCount == 1 && reply.getContent(0).typeCase == MessagingModel.Content.TypeCase.MEDIA && reply.getContent(0).isRenderable())) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/SealedMedia.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/SealedMedia.kt index 7f0eea171..beff6f259 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/SealedMedia.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/SealedMedia.kt @@ -17,7 +17,11 @@ internal object SealedMediaContract { fun isValid(media: MessagingModel.MediaContent): Boolean { if (media.itemsCount != 1) return false - val item = media.getItems(0) + return isValid(media.getItems(0)) + } + + /** One photo: a single ORIGINAL rendition carrying its own image metadata. */ + fun isValid(item: BlobModel.Media): Boolean { if (item.renditionsCount != 1) return false val rendition = item.getRenditions(0) if (rendition.role != BlobModel.Rendition.Role.ORIGINAL) return false @@ -72,7 +76,9 @@ fun checkBlobSealing(sealedFor: ChatId?, sendChatId: ChatId, chatSeals: Boolean) /** Whether [this] carries a photo, directly or as the body of a reply. */ fun MessageContent.carriesMedia(): Boolean = when (this) { is MessageContent.Media -> true - is MessageContent.Reply -> content.any { it is MessageContent.Media } + // A link preview's image is a chat photo too, sealed or not as the chat is. + is MessageContent.Text -> linkPreview?.image != null + is MessageContent.Reply -> content.any { it.carriesMedia() } else -> false } @@ -87,6 +93,17 @@ internal fun MessageContent.withoutDownloadUrls(): MessageContent = when (this) ) }, ) + is MessageContent.Text -> linkPreview?.image?.let { image -> + copy( + linkPreview = linkPreview.copy( + image = image.copy( + renditions = image.renditions.map { r -> + r.copy(blob = r.blob?.copy(downloadUrl = "", expiresAtMillis = null)) + }, + ), + ), + ) + } ?: this is MessageContent.Reply -> copy(content = content.map { it.withoutDownloadUrls() }) else -> this } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index b75d57d17..d03e2af62 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -127,7 +127,10 @@ internal fun ClientMessageId.asClientMessageId(): MessagingModel.ClientMessageId internal fun MessageContent.asContent(): MessagingModel.Content { return when (this) { is MessageContent.Text -> MessagingModel.Content.newBuilder() - .setText(MessagingModel.TextContent.newBuilder().setText(text)) + .setText( + MessagingModel.TextContent.newBuilder().setText(text) + .apply { this@asContent.linkPreview?.let { addLinkPreviews(it.asLinkPreview()) } } + ) .build() is MessageContent.Cash -> MessagingModel.Content.newBuilder() .setCash( @@ -192,6 +195,14 @@ internal fun MessageContent.asContent(): MessagingModel.Content { } } +internal fun com.flipcash.services.models.chat.LinkPreview.asLinkPreview(): MessagingModel.LinkPreview = + MessagingModel.LinkPreview.newBuilder() + .setUrl(url) + .setTitle(title) + .setDescription(description) + .apply { this@asLinkPreview.image?.let { setImage(it.asMediaItem()) } } + .build() + internal fun com.flipcash.services.models.chat.MediaItem.asMediaItem(): com.codeinc.flipcash.gen.blob.v1.Model.Media { return com.codeinc.flipcash.gen.blob.v1.Model.Media.newBuilder() .addAllRenditions(renditions.map { it.asRendition() }) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index cc00394f9..2c4f3f3df 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -45,6 +45,7 @@ import com.flipcash.services.models.chat.RejectionReason import com.flipcash.services.models.chat.ImageMetadata import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.services.models.chat.LinkPreview import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.isSystemBlank import com.flipcash.services.models.chat.WidgetContent @@ -171,9 +172,20 @@ internal fun Common.PagingToken.toPagingToken(): PagingToken = value.toByteArray // -- Messaging models -- +internal fun MessagingModel.LinkPreview.toLinkPreview(): LinkPreview = LinkPreview( + url = url, + title = title, + description = description, + image = if (hasImage()) image.toMediaItem() else null, +) + internal fun MessagingModel.Content.toMessageContent(): MessageContent { return when (typeCase) { - MessagingModel.Content.TypeCase.TEXT -> MessageContent.Text(text.text) + MessagingModel.Content.TypeCase.TEXT -> MessageContent.Text( + text = text.text, + // One is all this client renders; the contract has any further ones ignored. + linkPreview = text.linkPreviewsList.firstOrNull()?.toLinkPreview(), + ) MessagingModel.Content.TypeCase.CASH -> MessageContent.Cash( intentId = cash.intentId.value.toByteArray().toList(), amount = Fiat( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/LinkPreview.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/LinkPreview.kt new file mode 100644 index 000000000..186ee5f35 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/LinkPreview.kt @@ -0,0 +1,17 @@ +package com.flipcash.services.models.chat + +import kotlinx.serialization.Serializable + +/** + * A preview of a link in the text that carries it (`messaging.v1.LinkPreview`), generated by the + * sending device. These are the sender's claims about the page: show [url]'s host with it. + * + * [image] is one item with an ORIGINAL rendition, as for chat media. + */ +@Serializable +data class LinkPreview( + val url: String, + val title: String, + val description: String = "", + val image: MediaItem? = null, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 659c508f4..b96c84654 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -6,7 +6,12 @@ import com.getcode.solana.keys.Mint import kotlin.time.Instant sealed interface MessageContent { - data class Text(val text: String) : MessageContent + /** + * [linkPreview] is the sender-built preview of a link in [text] (`TextContent.link_previews`, + * first entry; later ones are ignored). Only a message's text and a reply's text body carry + * one; a media caption never does. + */ + data class Text(val text: String, val linkPreview: LinkPreview? = null) : MessageContent data class Cash( val intentId: ID, val amount: Fiat, From 59d3790efd8bec5401a54ddf80d716e0a020eaac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 17:33:27 -0400 Subject: [PATCH 02/10] feat(messenger): check a link preview's url and clean its text before sending Runs the shared link_preview_send.json vectors: 40 whole-link, 21 title and 11 description cases, with lengths counted in code points. --- .../internal/link/LinkPreviewChecks.kt | 118 +++++ .../internal/link/LinkPreviewVectorTest.kt | 65 +++ .../src/test/resources/link_preview_send.json | 415 ++++++++++++++++++ 3 files changed, 598 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewChecks.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewVectorTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/resources/link_preview_send.json diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewChecks.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewChecks.kt new file mode 100644 index 000000000..628b5b680 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewChecks.kt @@ -0,0 +1,118 @@ +package com.flipcash.app.messenger.internal.link + +/** + * The checks a sender runs before attaching a `messaging.v1.LinkPreview` to a text message: the + * url must be a whole link in the text, and the scraped title and description are cleaned to what + * the contract accepts. Pinned by `link_preview_send.json`, which the iOS app runs too; a + * disagreement is fixed in the canonical fixture, never here. + * + * Lengths are Unicode code points, as the contract counts them. Neither [String.length] (UTF-16 + * units) nor a grapheme count agrees with that, so nothing here uses them for a limit. + */ +internal object LinkPreviewChecks { + + const val URL_MAX = 2048 + const val TITLE_MAX = 256 + const val DESCRIPTION_MAX = 512 + + // Unicode White_Space, spelled out: Char.isWhitespace() counts U+001C-U+001F and omits U+00A0. + private val whiteSpace: Set = buildSet { + addAll(listOf(0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0x85, 0xA0, 0x1680)) + addAll(0x2000..0x200A) + addAll(listOf(0x2028, 0x2029, 0x202F, 0x205F, 0x3000)) + } + private val whiteSpaceChars: Set = whiteSpace.map { it.toChar() }.toSet() + + private val bidiEmbeddings: Set = (0x202A..0x202E).toSet() + (0x2066..0x2069).toSet() + private val bidiInUrl: Set = bidiEmbeddings + setOf(0x061C, 0x200E, 0x200F) + + private const val OPENERS = "([{<\"'" + private const val TRAILERS = ".,;:!?)]}>\"'" + private val labelChars: Set = (('a'..'z') + ('A'..'Z') + ('0'..'9') + '-').toSet() + private val hexChars: Set = (('0'..'9') + ('a'..'f') + ('A'..'F')).toSet() + + /** + * Whether [url] stands in [text] as a whole link the server will accept: a valid http(s) url + * that appears byte for byte, bounded by whitespace once surrounding openers and trailing + * punctuation are set aside. + */ + fun wholeLink(text: String, url: String): Boolean { + if (!urlOk(url)) return false + var start = text.indexOf(url) + while (start != -1) { + var i = start + while (i > 0 && text[i - 1] in OPENERS) i-- + val before = i == 0 || text[i - 1] in whiteSpaceChars + var j = start + url.length + while (j < text.length && text[j] in TRAILERS) j++ + val after = j == text.length || text[j] in whiteSpaceChars + if (before && after) return true + start = text.indexOf(url, start + 1) + } + return false + } + + /** The title to send for a scraped [raw] one, or null when nothing visible is left and the preview is dropped. */ + fun cleanTitle(raw: String): String? = clean(raw, TITLE_MAX, keepLineFeeds = false) + + /** The description to send for a scraped [raw] one; empty for none. */ + fun cleanDescription(raw: String): String = clean(raw, DESCRIPTION_MAX, keepLineFeeds = true).orEmpty() + + private fun urlOk(url: String): Boolean { + val length = url.codePointCount(0, url.length) + if (length !in 1..URL_MAX) return false + val rest = when { + url.startsWith("https://") -> url.removePrefix("https://") + url.startsWith("http://") -> url.removePrefix("http://") + else -> return false + } + if (url.codePoints().anyMatch { it in whiteSpace || it in bidiInUrl }) return false + val authority = rest.substringBefore('/').substringBefore('?').substringBefore('#') + if ('@' in authority) return false + var host = authority + if (':' in authority) { + host = authority.substringBeforeLast(':') + val port = authority.substringAfterLast(':') + if (port.isEmpty() || !port.all { it in '0'..'9' }) return false + val n = port.toLongOrNull() ?: return false + if (n !in 1..65535) return false + } + return hostOk(host) + } + + private fun hostOk(raw: String): Boolean { + val host = raw.removeSuffix(".") + if (host.isEmpty()) return false + val labels = host.split('.') + if (labels.any { it.isEmpty() || !it.all { c -> c in labelChars } }) return false + val last = labels.last() + if (last.all { it in '0'..'9' }) return false + if (last.take(2).lowercase() == "0x" && last.drop(2).all { it in hexChars }) return false + return true + } + + private fun clean(raw: String, limit: Int, keepLineFeeds: Boolean): String? { + val s = raw.replace("\r\n", "\n").replace('\r', '\n') + val out = StringBuilder() + s.codePoints().forEach { cp -> + when { + cp == 0x0A || cp == 0x2028 || cp == 0x2029 -> out.append(if (keepLineFeeds) '\n' else ' ') + cp == 0x09 || cp == 0x0B || cp == 0x0C -> out.append(' ') + Character.getType(cp) == Character.CONTROL.toInt() || cp in bidiEmbeddings -> Unit + else -> out.appendCodePoint(cp) + } + } + var cleaned = out.toString().trimWhiteSpace() + if (cleaned.codePointCount(0, cleaned.length) > limit) { + cleaned = cleaned.substring(0, cleaned.offsetByCodePoints(0, limit)) + } + cleaned = cleaned.trimWhiteSpace() + return cleaned.takeIf { visible(it) } + } + + private fun String.trimWhiteSpace(): String = trim { it in whiteSpaceChars } + + private fun visible(s: String): Boolean = s.codePoints().anyMatch { + it !in whiteSpace && Character.getType(it) != Character.FORMAT.toInt() + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewVectorTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewVectorTest.kt new file mode 100644 index 000000000..3c6c14088 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewVectorTest.kt @@ -0,0 +1,65 @@ +package com.flipcash.app.messenger.internal.link + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.fail + +/** + * Runs `link_preview_send.json`: the whole-link rule, title cleaning and description cleaning a + * sender applies before attaching a link preview. The fixture is a synced copy, so a disagreement + * is fixed in the canonical file, never here. All failing vectors are reported together. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class LinkPreviewVectorTest { + + private val fixture: JSONObject = JSONObject( + javaClass.classLoader!!.getResourceAsStream("link_preview_send.json")!! + .bufferedReader().use { it.readText() } + ) + + private fun JSONArray.objects() = (0 until length()).map { getJSONObject(it) } + + private fun check(section: String, run: (JSONObject) -> String?) { + val failures = fixture.getJSONArray(section).objects().mapNotNull { v -> + run(v)?.let { "${v.getString("name")}: $it" } + } + if (failures.isNotEmpty()) fail("$section vectors failed:\n" + failures.joinToString("\n")) + } + + @Test + fun `limits match the fixture`() { + val limits = fixture.getJSONObject("limits") + kotlin.test.assertEquals(limits.getInt("url"), LinkPreviewChecks.URL_MAX) + kotlin.test.assertEquals(limits.getInt("title"), LinkPreviewChecks.TITLE_MAX) + kotlin.test.assertEquals(limits.getInt("description"), LinkPreviewChecks.DESCRIPTION_MAX) + } + + @Test + fun `whole link vectors`() = check("wholeLink") { v -> + val got = LinkPreviewChecks.wholeLink(v.getString("text"), v.getString("url")) + if (got != v.getBoolean("accepted")) "expected ${v.getBoolean("accepted")}, got $got" else null + } + + @Test + fun `title vectors`() = check("title") { v -> + val expected = if (v.isNull("expected")) null else v.getString("expected") + val got = LinkPreviewChecks.cleanTitle(v.getString("raw")) + if (got != expected) "expected ${expected?.let { escape(it) }}, got ${got?.let { escape(it) }}" else null + } + + @Test + fun `description vectors`() = check("description") { v -> + val expected = v.getString("expected") + val got = LinkPreviewChecks.cleanDescription(v.getString("raw")) + if (got != expected) "expected ${escape(expected)}, got ${escape(got)}" else null + } + + private fun escape(s: String) = s.codePoints().toArray().joinToString("", "\"", "\"") { + if (it in 0x20..0x7e) it.toChar().toString() else "\\u{%x}".format(it) + } +} diff --git a/apps/flipcash/features/messenger/src/test/resources/link_preview_send.json b/apps/flipcash/features/messenger/src/test/resources/link_preview_send.json new file mode 100644 index 000000000..216baa03c --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/resources/link_preview_send.json @@ -0,0 +1,415 @@ +{ + "algorithm": "flipcash2 0.19.0 LinkPreview sender checks (whole link, title, description)", + "note": "Code points throughout. See gen_link_preview_send.py for the cleaning rules.", + "limits": { + "url": 2048, + "title": 256, + "description": 512 + }, + "wholeLink": [ + { + "name": "plain", + "text": "https://a.example", + "url": "https://a.example", + "accepted": true + }, + { + "name": "in a sentence", + "text": "see https://a.example now", + "url": "https://a.example", + "accepted": true + }, + { + "name": "brackets and trailing punctuation", + "text": "see (https://a.example).", + "url": "https://a.example", + "accepted": true + }, + { + "name": "quotes", + "text": "\"https://a.example\"", + "url": "https://a.example", + "accepted": true + }, + { + "name": "run of openers and trailers", + "text": "<[(https://a.example)]>!?", + "url": "https://a.example", + "accepted": true + }, + { + "name": "newline is whitespace", + "text": "x\nhttps://a.example\ny", + "url": "https://a.example", + "accepted": true + }, + { + "name": "no-break space is whitespace", + "text": "x\u00a0https://a.example", + "url": "https://a.example", + "accepted": true + }, + { + "name": "path, query, fragment", + "text": "go https://a.example/p/q?x=1#f", + "url": "https://a.example/p/q?x=1#f", + "accepted": true + }, + { + "name": "url ending in a paren it owns", + "text": "https://w.example/Foo_(bar)", + "url": "https://w.example/Foo_(bar)", + "accepted": true + }, + { + "name": "the shorter reading also holds", + "text": "https://w.example/Foo_(bar)", + "url": "https://w.example/Foo_(bar", + "accepted": true + }, + { + "name": "http", + "text": "http://a.example", + "url": "http://a.example", + "accepted": true + }, + { + "name": "port", + "text": "https://a.example:8443/x", + "url": "https://a.example:8443/x", + "accepted": true + }, + { + "name": "trailing dot host", + "text": "https://a.example./x", + "url": "https://a.example./x", + "accepted": true + }, + { + "name": "second occurrence is whole", + "text": "https://a.example.evil.example https://a.example", + "url": "https://a.example", + "accepted": true + }, + { + "name": "at the url length limit", + "text": "https://a.example/pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "url": "https://a.example/pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "accepted": true + }, + { + "name": "suffix continues the host", + "text": "https://a.example.evil.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "inside another url", + "text": "https://evil.example/(https://a.example)", + "url": "https://a.example", + "accepted": false + }, + { + "name": "glued to a word before", + "text": "xhttps://a.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "glued to a word after", + "text": "https://a.examplex", + "url": "https://a.example", + "accepted": false + }, + { + "name": "trailer then a word", + "text": "https://a.example.x", + "url": "https://a.example", + "accepted": false + }, + { + "name": "bare domain resolved by the detector", + "text": "see a.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "uppercase scheme in text", + "text": "HTTPS://a.example", + "url": "HTTPS://a.example", + "accepted": false + }, + { + "name": "detector lowercased the scheme", + "text": "HTTPS://a.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "case-folded host", + "text": "https://A.example", + "url": "https://a.example", + "accepted": false + }, + { + "name": "percent-decoded", + "text": "https://a.example/a%20b", + "url": "https://a.example/a b", + "accepted": false + }, + { + "name": "ftp", + "text": "ftp://a.example", + "url": "ftp://a.example", + "accepted": false + }, + { + "name": "userinfo", + "text": "https://u:p@a.example", + "url": "https://u:p@a.example", + "accepted": false + }, + { + "name": "ipv4", + "text": "https://10.0.0.1/", + "url": "https://10.0.0.1/", + "accepted": false + }, + { + "name": "numeric last label", + "text": "https://a.123", + "url": "https://a.123", + "accepted": false + }, + { + "name": "hex last label", + "text": "https://a.0x7f", + "url": "https://a.0x7f", + "accepted": false + }, + { + "name": "ipv6", + "text": "https://[::1]/", + "url": "https://[::1]/", + "accepted": false + }, + { + "name": "punycode is fine", + "text": "https://xn--bcher-kva.example", + "url": "https://xn--bcher-kva.example", + "accepted": true + }, + { + "name": "non-ascii host", + "text": "https://b\u00fccher.example", + "url": "https://b\u00fccher.example", + "accepted": false + }, + { + "name": "percent in host", + "text": "https://a%2e.example", + "url": "https://a%2e.example", + "accepted": false + }, + { + "name": "empty label", + "text": "https://a..example", + "url": "https://a..example", + "accepted": false + }, + { + "name": "port zero", + "text": "https://a.example:0", + "url": "https://a.example:0", + "accepted": false + }, + { + "name": "port too big", + "text": "https://a.example:65536", + "url": "https://a.example:65536", + "accepted": false + }, + { + "name": "bidi mark in url", + "text": "https://a.example/\u200ex", + "url": "https://a.example/\u200ex", + "accepted": false + }, + { + "name": "over the url length limit", + "text": "https://a.example/ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "url": "https://a.example/ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "accepted": false + }, + { + "name": "not in the text", + "text": "hello", + "url": "https://a.example", + "accepted": false + } + ], + "title": [ + { + "name": "plain", + "raw": "Hello", + "expected": "Hello" + }, + { + "name": "trimmed", + "raw": " Hello \t", + "expected": "Hello" + }, + { + "name": "line feed becomes a space", + "raw": "Hello\nWorld", + "expected": "Hello World" + }, + { + "name": "crlf becomes one space", + "raw": "Hello\r\nWorld", + "expected": "Hello World" + }, + { + "name": "line separator becomes a space", + "raw": "Hello\u2028World", + "expected": "Hello World" + }, + { + "name": "controls removed", + "raw": "He\u0000ll\u0007o\u007f\u0085", + "expected": "Hello" + }, + { + "name": "tab becomes a space", + "raw": "Hello\tWorld", + "expected": "Hello World" + }, + { + "name": "bidi override removed", + "raw": "\u202eHello\u202c", + "expected": "Hello" + }, + { + "name": "bidi isolate removed", + "raw": "\u2066Hello\u2069", + "expected": "Hello" + }, + { + "name": "bidi marks kept", + "raw": "\u200fHello\u200e", + "expected": "\u200fHello\u200e" + }, + { + "name": "zero-width space kept beside text", + "raw": "Hel\u200blo", + "expected": "Hel\u200blo" + }, + { + "name": "only whitespace drops", + "raw": " ", + "expected": null + }, + { + "name": "only zero-width drops", + "raw": "\u200b\u200d\ufeff", + "expected": null + }, + { + "name": "only bidi drops", + "raw": "\u202e\u202c", + "expected": null + }, + { + "name": "only marks drops", + "raw": "\u200e\u200f", + "expected": null + }, + { + "name": "empty drops", + "raw": "", + "expected": null + }, + { + "name": "cut at 256 code points", + "raw": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "name": "astral counts once", + "raw": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00", + "expected": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00" + }, + { + "name": "flag is two code points", + "raw": "\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8", + "expected": "\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8" + }, + { + "name": "cut splits a flag", + "raw": "a\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8", + "expected": "a\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa\ud83c\uddf8\ud83c\uddfa" + }, + { + "name": "cut lands before a space", + "raw": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa b", + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "description": [ + { + "name": "plain", + "raw": "A page.", + "expected": "A page." + }, + { + "name": "line feeds kept", + "raw": "One\nTwo", + "expected": "One\nTwo" + }, + { + "name": "crlf becomes a line feed", + "raw": "One\r\nTwo", + "expected": "One\nTwo" + }, + { + "name": "paragraph separator becomes a line feed", + "raw": "One\u2029Two", + "expected": "One\nTwo" + }, + { + "name": "trim drops edge newlines", + "raw": "\n\nOne\n", + "expected": "One" + }, + { + "name": "controls removed", + "raw": "On\u001be\u0000", + "expected": "One" + }, + { + "name": "bidi override removed", + "raw": "\u202dOne", + "expected": "One" + }, + { + "name": "only whitespace is empty", + "raw": " \n ", + "expected": "" + }, + { + "name": "only format is empty", + "raw": "\u200b", + "expected": "" + }, + { + "name": "cut at 512 code points", + "raw": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "expected": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + { + "name": "astral counts once", + "raw": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00", + "expected": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00" + } + ] +} From ce700ab717b2aa73f102efd637165266e74fbdc9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 20:11:09 -0400 Subject: [PATCH 03/10] refactor(chat): move link preview checks into shared chat The send path lives in shared/chat, which cannot depend on the messenger feature module, so the checks and their vector test move there. --- .../kotlin/com/flipcash/shared/chat}/link/LinkPreviewChecks.kt | 0 .../com/flipcash/shared/chat}/link/LinkPreviewVectorTest.kt | 0 .../chat}/src/test/resources/link_preview_send.json | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename apps/flipcash/{features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal => shared/chat/src/main/kotlin/com/flipcash/shared/chat}/link/LinkPreviewChecks.kt (100%) rename apps/flipcash/{features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal => shared/chat/src/test/kotlin/com/flipcash/shared/chat}/link/LinkPreviewVectorTest.kt (100%) rename apps/flipcash/{features/messenger => shared/chat}/src/test/resources/link_preview_send.json (100%) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewChecks.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewChecks.kt similarity index 100% rename from apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewChecks.kt rename to apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewChecks.kt diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewVectorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/link/LinkPreviewVectorTest.kt similarity index 100% rename from apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkPreviewVectorTest.kt rename to apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/link/LinkPreviewVectorTest.kt diff --git a/apps/flipcash/features/messenger/src/test/resources/link_preview_send.json b/apps/flipcash/shared/chat/src/test/resources/link_preview_send.json similarity index 100% rename from apps/flipcash/features/messenger/src/test/resources/link_preview_send.json rename to apps/flipcash/shared/chat/src/test/resources/link_preview_send.json From a0308107d5fa86d05839e1321217a1a5aaba7d4f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 20:17:59 -0400 Subject: [PATCH 04/10] feat(chat): carry a link preview through send, retry and edit Send attaches the ready preview after the whole-link check on the wire text and waits for its image upload, sending without the image if the upload fails. The pending row keeps the preview so a retry sends it again, and an edit keeps the stored preview only while its url is still a whole link in the new text, without fetching the page again. --- .../flipcash/shared/chat/ChatCoordinator.kt | 6 ++ .../flipcash/shared/chat/PendingMutation.kt | 7 +- .../flipcash/shared/chat/inject/ChatModule.kt | 7 ++ .../internal/delegates/MediaSendDelegate.kt | 24 ++---- .../internal/delegates/MessagingDelegate.kt | 75 +++++++++++++++---- .../shared/chat/link/LinkPreviewChecks.kt | 4 +- .../shared/chat/link/LinkPreviewImages.kt | 53 +++++++++++++ .../shared/chat/link/OutgoingLinkPreview.kt | 14 ++++ .../shared/chat/media/UploadedPhotoItem.kt | 24 ++++++ .../shared/chat/link/LinkPreviewVectorTest.kt | 2 +- 10 files changed, 179 insertions(+), 37 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewImages.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/OutgoingLinkPreview.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/UploadedPhotoItem.kt 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 99ca1bb6e..433721b6c 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 @@ -22,6 +22,7 @@ import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.TypingState import com.flipcash.services.repository.ReactorsPage +import com.flipcash.shared.chat.link.OutgoingLinkPreview import com.flipcash.shared.chat.media.ChatMediaSending import com.flipcash.shared.chat.reactions.ReactionError import com.flipcash.shared.chat.reactions.ReactionPill @@ -471,11 +472,16 @@ interface MessagingOperations { * [replyToMessageId] cites a message in the same chat, which wraps the body in * [MessageContent.Reply]. It defaults to `null` so a caller with no message to cite — the * notification quick-reply replies to a conversation, not to a message — is unchanged. + * + * [linkPreview] rides on the text when its url stands in [content] as a whole link, and is + * dropped otherwise. Its image is waited for after the pending row is written, so the text + * shows as sending at once; an image that fails to upload sends the preview without it. */ suspend fun sendMessage( chatId: ChatId, content: String, replyToMessageId: Long? = null, + linkPreview: OutgoingLinkPreview? = null, ): Result /** Retries a failed pending message: resets to SENDING and re-sends to the server. */ diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PendingMutation.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PendingMutation.kt index fcaa3eadd..f8f644493 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PendingMutation.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PendingMutation.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat +import com.flipcash.shared.chat.link.LinkPreviewChecks import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessageContent import com.getcode.opencode.model.core.ID @@ -63,7 +64,11 @@ fun ChatMessage.applying(mutation: PendingMutation?): ChatMessage { internal fun List.replacingText(text: String): List = map { content -> when (content) { - is MessageContent.Text -> MessageContent.Text(text) + // The preview stays while its link is still in the text; the server checks the same. + is MessageContent.Text -> MessageContent.Text( + text = text, + linkPreview = content.linkPreview?.takeIf { LinkPreviewChecks.wholeLink(text, it.url) }, + ) is MessageContent.Reply -> content.copy(content = content.content.replacingText(text)) else -> content } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 2bbbb0ec8..fc277f5f8 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -1,5 +1,7 @@ package com.flipcash.shared.chat.inject +import com.flipcash.shared.chat.link.LinkPreviewImages +import com.flipcash.shared.chat.link.UploadedLinkPreviewImages import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore @@ -71,6 +73,11 @@ abstract class ChatModule { impl: MediaSendDelegate ): ChatMediaSending + @Binds + internal abstract fun bindLinkPreviewImages( + impl: UploadedLinkPreviewImages + ): LinkPreviewImages + @Binds internal abstract fun bindOutgoingEncryption( impl: DmOutgoingEncryption diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt index f643f84f7..067feb551 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt @@ -25,6 +25,7 @@ import com.flipcash.shared.chat.media.ChatMediaUploadState import com.flipcash.shared.chat.media.ChatMediaUploads import com.flipcash.shared.chat.media.MediaSendProgress import com.flipcash.shared.chat.media.UploadedPhoto +import com.flipcash.shared.chat.media.toMediaItem import com.getcode.opencode.model.core.RandomId import com.getcode.utils.TraceType import com.getcode.utils.hexEncodedString @@ -430,30 +431,15 @@ class MediaSendDelegate internal constructor( ), ) - /** - * What goes to the server. A sealed photo carries the full metadata its contract needs; a plain - * one only its id, the server filling in the rest from the bytes. - */ - private fun sentContent(record: PendingMediaRecord, photo: UploadedPhoto): List { - val sealed = photo.sealedFor != null - val blob = if (sealed) { - BlobMetadata( - mimeType = JPEG, - sizeBytes = photo.sizeBytes, - downloadUrl = "", - image = ImageMetadata(photo.width, photo.height, photo.blurhash.orEmpty()), - ) - } else null - return wrap( + /** What goes to the server. */ + private fun sentContent(record: PendingMediaRecord, photo: UploadedPhoto): List = + wrap( record.replyToMessageId, MessageContent.Media( - items = listOf( - MediaItem(listOf(MediaItemRendition(MediaItemRendition.Role.ORIGINAL, photo.blobId, blob))), - ), + items = listOf(photo.toMediaItem()), caption = record.caption?.let { MessageContent.Text(it) }, ), ) - } private fun wrap(replyTo: Long?, media: MessageContent.Media): List = listOf(replyTo?.let { MessageContent.Reply(it, listOf(media)) } ?: media) 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 8d67ab2b6..d15b46373 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 @@ -2,6 +2,11 @@ package com.flipcash.shared.chat.internal.delegates +import com.flipcash.shared.chat.link.OutgoingLinkPreview +import com.flipcash.shared.chat.link.LinkPreviewImages +import com.flipcash.shared.chat.link.LinkPreviewChecks +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.LinkPreview import com.flipcash.services.models.SendMessageError import androidx.core.app.NotificationManagerCompat import androidx.paging.ExperimentalPagingApi @@ -103,6 +108,7 @@ class MessagingDelegate @Inject constructor( private val outgoing: OutgoingEncryption = OutgoingEncryption.None, /** Opens encrypted pushes; without it, every push keeps the server's body. */ private val incoming: IncomingMessageOpener? = null, + private val previewImages: LinkPreviewImages = LinkPreviewImages.None, ) : MessagingOperations { private val webGate = WebPreviewGate(stateHolder, metadataDataSource) @@ -395,6 +401,7 @@ class MessagingDelegate @Inject constructor( chatId: ChatId, content: String, replyToMessageId: Long?, + linkPreview: OutgoingLinkPreview?, ): Result { if (content.isBlank()) { return Result.failure(IllegalArgumentException("Cannot send a blank message")) @@ -403,19 +410,35 @@ class MessagingDelegate @Inject constructor( val senderId = userManager.accountId ?: return Result.failure(IllegalStateException("Cannot send message without an account")) - val body = listOf(MessageContent.Text(content)) - // The optimistic row and the request carry the same payload, so the quote renders before - // the server answers rather than appearing when it does. - val payload = replyToMessageId - ?.let { listOf(MessageContent.Reply(repliedMessageId = it, content = body)) } - ?: body + val preview = linkPreview?.takeIf { LinkPreviewChecks.wholeLink(content, it.url) } + fun payload(image: MediaItem?): List { + val body = listOf( + MessageContent.Text( + text = content, + linkPreview = preview?.let { LinkPreview(it.url, it.title, it.description, image) }, + ), + ) + // The optimistic row and the request carry the same payload, so the quote renders + // before the server answers rather than appearing when it does. + return replyToMessageId + ?.let { listOf(MessageContent.Reply(repliedMessageId = it, content = body)) } + ?: body + } + val (_, clientMessageId) = messageDataSource.insertPending( chatId = chatId, - content = payload, + content = payload(image = null), senderId = senderId, ) + if (preview == null) linkPreview?.imageUploadId?.let(previewImages::discard) + + val image = preview?.imageUploadId?.let { previewImages.settle(it) } + ?: return send(chatId, payload(image = null), clientMessageId) - return send(chatId, payload, clientMessageId) + // Rewritten with the image, so a retry of a failed send keeps it. + val withImage = payload(image.item) + messageDataSource.insertPending(chatId, withImage, senderId, clientMessageId, ordinal = 0) + return send(chatId, withImage, clientMessageId) } override suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result { @@ -436,11 +459,12 @@ class MessagingDelegate @Inject constructor( payload: List, clientMessageId: ClientMessageId, ): Result { - val prepared = outgoing.prepare(chatId, payload).getOrElse { cause -> - trace(tag = TAG, message = "Couldn't prepare send in $chatId", type = TraceType.Error, error = cause) - messageDataSource.failPending(chatId, clientMessageId) - return Result.failure(cause) - } + val prepared = outgoing.prepare(chatId, payload, blobSealedFor = payload.previewImageSealedFor(chatId)) + .getOrElse { cause -> + trace(tag = TAG, message = "Couldn't prepare send in $chatId", type = TraceType.Error, error = cause) + messageDataSource.failPending(chatId, clientMessageId) + return Result.failure(cause) + } return messagingController.sendMessage(chatId, prepared.wire, clientMessageId) .map(prepared::echo) @@ -488,7 +512,13 @@ class MessagingDelegate @Inject constructor( ), ) - val prepared = outgoing.prepare(chatId, content, wasEncrypted = stored.encryption != null) + // A sealed message's preview image was sealed for this chat along with it. + val prepared = outgoing.prepare( + chatId, + content, + wasEncrypted = stored.encryption != null, + blobSealedFor = chatId.takeIf { stored.encryption != null }, + ) .getOrElse { cause -> clearMutation(chatId, messageId) return Result.failure(cause) @@ -737,3 +767,20 @@ class MessagingDelegate @Inject constructor( const val TAG = "MessagingDelegate" } } + +/** + * The chat a preview image in this payload was sealed for, read from the item [sendMessage] built: + * a sealed image carries its metadata with no download url, a plain one carries none. Read off the + * content so a retry, which only has the stored row, answers the same as the first attempt. + */ +private fun List.previewImageSealedFor(chatId: ChatId): ChatId? { + val image = firstNotNullOfOrNull { content -> + when (content) { + is MessageContent.Text -> content.linkPreview?.image + is MessageContent.Reply -> content.content.filterIsInstance().firstNotNullOfOrNull { it.linkPreview?.image } + else -> null + } + } ?: return null + val blob = image.renditions.firstOrNull()?.blob + return chatId.takeIf { blob != null && blob.downloadUrl.isEmpty() } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewChecks.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewChecks.kt index 628b5b680..4f5115d71 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewChecks.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewChecks.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.messenger.internal.link +package com.flipcash.shared.chat.link /** * The checks a sender runs before attaching a `messaging.v1.LinkPreview` to a text message: the @@ -9,7 +9,7 @@ package com.flipcash.app.messenger.internal.link * Lengths are Unicode code points, as the contract counts them. Neither [String.length] (UTF-16 * units) nor a grapheme count agrees with that, so nothing here uses them for a limit. */ -internal object LinkPreviewChecks { +object LinkPreviewChecks { const val URL_MAX = 2048 const val TITLE_MAX = 256 diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewImages.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewImages.kt new file mode 100644 index 000000000..65025f021 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewImages.kt @@ -0,0 +1,53 @@ +package com.flipcash.shared.chat.link + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.chat.media.ChatMediaUploadState +import com.flipcash.shared.chat.media.ChatMediaUploads +import com.flipcash.shared.chat.media.ChatPhoto +import com.flipcash.shared.chat.media.toMediaItem +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** A preview image ready to send, and the chat its bytes were sealed for (null if they went up plain). */ +class SettledPreviewImage(val item: MediaItem, val sealedFor: ChatId?) + +interface LinkPreviewImages { + /** + * Waits for chip [id] to upload and returns it as a preview image, or null if it failed, was + * removed or is still going after the deadline. The chip is removed either way. + */ + suspend fun settle(id: String): SettledPreviewImage? + + /** Drops chip [id] unsent, for a preview that won't go out. */ + fun discard(id: String) + + object None : LinkPreviewImages { + override suspend fun settle(id: String): SettledPreviewImage? = null + override fun discard(id: String) = Unit + } +} + +internal class UploadedLinkPreviewImages( + private val uploads: ChatMediaUploads, + private val deadline: Duration, +) : LinkPreviewImages { + @Inject constructor(uploads: ChatMediaUploads) : this(uploads, DEADLINE) + + override suspend fun settle(id: String): SettledPreviewImage? { + val settled = withTimeoutOrNull(deadline) { uploads.awaitSettled(id) } + val photo = (settled as? ChatMediaUploadState.Uploaded)?.photo + // Kept under the blob's cache key, so the sent bubble draws the image without fetching it back. + uploads.remove(id, keepAs = photo?.let { ChatPhoto.cacheKeyOf(it.blobId) }) + return photo?.let { SettledPreviewImage(it.toMediaItem(), it.sealedFor) } + } + + override fun discard(id: String) = uploads.remove(id) + + private companion object { + // The text is already on screen as sending; past this it goes without the image. + val DEADLINE = 20.seconds + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/OutgoingLinkPreview.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/OutgoingLinkPreview.kt new file mode 100644 index 000000000..abf209aa1 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/OutgoingLinkPreview.kt @@ -0,0 +1,14 @@ +package com.flipcash.shared.chat.link + +/** + * A preview the composer built for a link in the text it is about to send. + * + * [imageUploadId] is the [ChatMediaUploads][com.flipcash.shared.chat.media.ChatMediaUploads] chip + * holding the page's image, staged when the preview loaded; null when the page has no image. + */ +data class OutgoingLinkPreview( + val url: String, + val title: String, + val description: String = "", + val imageUploadId: String? = null, +) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/UploadedPhotoItem.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/UploadedPhotoItem.kt new file mode 100644 index 000000000..995dad5b8 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/UploadedPhotoItem.kt @@ -0,0 +1,24 @@ +package com.flipcash.shared.chat.media + +import com.flipcash.services.models.chat.BlobMetadata +import com.flipcash.services.models.chat.ImageMetadata +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.MediaItemRendition + +/** + * The item that sends [this] photo. A sealed photo carries the full metadata its contract needs; a + * plain one only its id, the server filling in the rest from the bytes. + */ +internal fun UploadedPhoto.toMediaItem(): MediaItem { + val blob = if (sealedFor != null) { + BlobMetadata( + mimeType = JPEG, + sizeBytes = sizeBytes, + downloadUrl = "", + image = ImageMetadata(width, height, blurhash.orEmpty()), + ) + } else null + return MediaItem(listOf(MediaItemRendition(MediaItemRendition.Role.ORIGINAL, blobId, blob))) +} + +private const val JPEG = "image/jpeg" diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/link/LinkPreviewVectorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/link/LinkPreviewVectorTest.kt index 3c6c14088..71118c1b4 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/link/LinkPreviewVectorTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/link/LinkPreviewVectorTest.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.messenger.internal.link +package com.flipcash.shared.chat.link import org.json.JSONArray import org.json.JSONObject From 7e1ebc3fb8c3b8d91a5d589a903096e799f63ff1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 22:24:32 -0400 Subject: [PATCH 05/10] feat(messenger): build a link preview from the draft and stage its image before send The composer follows the draft's first web link, looks the page up with the existing WebLinkLookup, downloads its image through the guarded image client and stages it as a chat photo. The send takes the preview before the composer empties; a photo message drops it. --- .../app/messenger/internal/ChatViewModel.kt | 35 ++++- .../internal/link/ComposerLinkPreview.kt | 139 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index eeb9b3219..5a4ab0c23 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -133,6 +133,7 @@ import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.models.splitAroundLinkCard import coil3.ImageLoader +import com.flipcash.app.messenger.internal.link.ComposerLinkPreview import com.flipcash.app.messenger.internal.link.WebPreviewImages import com.flipcash.shared.chat.reactions.ReactionError import com.flipcash.shared.chat.reactions.ReactionPill @@ -198,6 +199,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -279,6 +281,7 @@ internal class ChatViewModel @AssistedInject constructor( @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, linkCardMemory: LinkCardMemory? = null, featureFlags: FeatureFlagController = NoOpFeatureFlagController, + private val composerLinkPreview: ComposerLinkPreview? = null, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -3099,6 +3102,22 @@ internal class ChatViewModel @AssistedInject constructor( } private fun initSendHandlers() { + composerLinkPreview?.let { preview -> + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .onEach { chatId -> + coroutineScope { + // An edit keeps the message's own preview, so its text builds none. + val draft = combine( + snapshotFlow { stateFlow.value.chatInputState.text.toString() }, + stateFlow.map { it.editing != null }.distinctUntilChanged(), + ) { text, editing -> if (editing) "" else text } + preview.bind(this, chatId, draft) + } + } + .launchIn(viewModelScope) + } + // Send text message eventFlow.filterIsInstance() .onEach { @@ -3111,6 +3130,15 @@ internal class ChatViewModel @AssistedInject constructor( // both are the composer emptying itself once the message is on its way. val replyToMessageId = stateFlow.value.replyingTo?.messageId + // Taken before the draft empties, which would otherwise drop it. A photo message + // carries no link preview. + val linkPreview = if (photos.isEmpty()) { + composerLinkPreview?.take(textToSend) + } else { + composerLinkPreview?.clear() + null + } + stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("") if (replyToMessageId != null) dispatchEvent(Event.CancelReply) // The chips leave the composer now; the send owns them from here, and they come @@ -3123,7 +3151,12 @@ internal class ChatViewModel @AssistedInject constructor( viewModelScope.launch { val sent = if (photos.isEmpty()) { - chatCoordinator.sendMessage(chatId, textToSend, replyToMessageId) + chatCoordinator.sendMessage( + chatId, + textToSend, + replyToMessageId, + linkPreview = linkPreview, + ) } else { chatCoordinator.sendMedia(chatId, photos.map { it.id }, textToSend.trim(), replyToMessageId) .onFailure { dispatchEvent(Event.PhotosRestored(photos)) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt new file mode 100644 index 000000000..2e3419d81 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt @@ -0,0 +1,139 @@ +package com.flipcash.app.messenger.internal.link + +import android.content.Context +import android.net.Uri +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.link.LinkPreviewChecks +import com.flipcash.shared.chat.link.LinkPreviewImages +import com.flipcash.shared.chat.link.OutgoingLinkPreview +import com.flipcash.shared.chat.media.ChatMediaUploadState +import com.flipcash.shared.chat.media.ChatMediaUploads +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.ui.detectUrls +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +/** + * Builds the preview for the first web link in the draft, so the sender, not each receiver, fetches + * the page. The page's image is downloaded through the guarded image client and staged as a chat + * photo as soon as the preview loads, so by send time it is usually already up. + * + * The staged image is not a composer chip: chips come from the composer's own staged photos. + */ +internal class ComposerLinkPreview @Inject constructor( + private val classifier: LinkCardClassifier, + private val lookup: WebLinkLookup, + @WebPreviewImages private val imageClient: OkHttpClient, + private val uploads: ChatMediaUploads, + private val images: LinkPreviewImages, + @ApplicationContext private val context: Context, + private val dispatchers: DispatcherProvider, +) { + private val ready = AtomicReference(null) + + /** Follows [draft] for [chatId] until [scope] ends, keeping at most one preview ready. */ + @OptIn(FlowPreview::class) + fun bind(scope: CoroutineScope, chatId: ChatId, draft: Flow) { + scope.launch { + try { + draft + .debounce(DEBOUNCE) + .map(::firstWebUrl) + .distinctUntilChanged() + .collectLatest { url -> + clear() + if (url != null) prepare(scope, chatId, url) + } + } finally { + clear() + } + } + } + + /** + * Hands over the ready preview for [text] and forgets it. Null when none is ready or its link is + * no longer in the text; the send makes the authoritative whole-link check on the wire text. + */ + fun take(text: String): OutgoingLinkPreview? { + val preview = ready.getAndSet(null) ?: return null + if (text.contains(preview.url)) return preview + preview.imageUploadId?.let(images::discard) + return null + } + + /** Drops the ready preview and its staged image, for a draft that won't send it. */ + fun clear() { + ready.getAndSet(null)?.imageUploadId?.let(images::discard) + } + + private fun firstWebUrl(text: String): String? = + (classifier.firstCard(detectUrls(text)) as? LinkCard.Web)?.url + + private suspend fun prepare(scope: CoroutineScope, chatId: ChatId, url: String) { + val page = lookup(url).getOrNull() as? LinkCard.Web.State.Resolved ?: return + val title = LinkPreviewChecks.cleanTitle(page.title) ?: return + val description = LinkPreviewChecks.cleanDescription(page.description.orEmpty()) + var imageId: String? = null + try { + imageId = page.imageUrl?.let { stageImage(scope, chatId, it) } + ready.set(OutgoingLinkPreview(url, title, description, imageId)) + } catch (e: CancellationException) { + // The draft moved on while the image was staging; nothing will send it. + imageId?.let(images::discard) + throw e + } + } + + /** Downloads [imageUrl] and stages it; null if the download fails. */ + private suspend fun stageImage(scope: CoroutineScope, chatId: ChatId, imageUrl: String): String? { + val file = download(imageUrl) ?: return null + val id = uploads.stage(chatId, Uri.fromFile(file)) + // Past preparing, the uploads hold their own encoded copy. + scope.launch { + try { + uploads.state(id).first { it !is ChatMediaUploadState.Preparing } + } finally { + file.delete() + } + } + return id + } + + private suspend fun download(imageUrl: String): File? = withContext(dispatchers.IO) { + val file = File(directory(), UUID.randomUUID().toString()) + runCatching { + imageClient.newCall(Request.Builder().url(imageUrl).build()).execute().use { response -> + if (!response.isSuccessful) return@runCatching null + file.outputStream().use { out -> response.body.byteStream().copyTo(out) } + file + } + }.onFailure { if (it is CancellationException) throw it } + .getOrNull() + ?: run { file.delete(); null } + } + + private fun directory() = File(context.cacheDir, DIRECTORY).apply { mkdirs() } + + private companion object { + val DEBOUNCE = 400.milliseconds + const val DIRECTORY = "link-preview-images" + } +} From de2e55a7f9ba678ae908a443f1daeac236954822 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 22:30:42 -0400 Subject: [PATCH 06/10] feat(messenger): draw a sender-built link preview without fetching the page A text whose linkPreview matches its web card's link gets the card resolved from the preview, with the image as a chat photo through the app loader. It draws without the tap-to-load chip, since the reader contacts no site, and still draws nothing with previews off. The prefetcher skips it. --- .../app/messenger/internal/ChatViewModel.kt | 29 +++++++++++++ .../internal/link/MessageLinkPrefetcher.kt | 3 ++ .../link/MessageLinkPrefetcherTest.kt | 15 +++++++ .../flipcash/shared/chat/models/LinkCard.kt | 7 +++- .../flipcash/shared/chat/ui/WebLinkCard.kt | 42 ++++++++++++++++++- .../shared/chat/ui/WebLinkCardTest.kt | 28 +++++++++++-- .../flipcash/shared/chat/ui/LinkDetection.kt | 8 ++++ 7 files changed, 126 insertions(+), 6 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 5a4ab0c23..47519decb 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1,5 +1,9 @@ package com.flipcash.app.messenger.internal +import com.flipcash.shared.chat.media.ChatPhoto +import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.services.models.chat.LinkPreview +import com.flipcash.shared.chat.ui.linkPreview import android.content.ClipboardManager import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd @@ -1301,6 +1305,7 @@ internal class ChatViewModel @AssistedInject constructor( val linkableText = enriched.linkableText() val links = linkableText?.let(::detectUrls).orEmpty() val linkCard = linkCardClassifier.firstCard(links) + .withSenderPreview(content.linkPreview(), message, stateFlow.value.chatId) // Detected over the whole text, beside the links, so a handle inside a link // stays the link's. Nothing is looked up until one is tapped. @@ -3859,3 +3864,27 @@ private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State { } return copy(quickReactionStrip = strip) } + +/** + * The web card resolved from the preview its sender built, when the preview is for this card's + * link: the reader then fetches nothing. Any other card, or a preview for another link, is left as + * classified. + */ +private fun LinkCard?.withSenderPreview(preview: LinkPreview?, message: ChatMessage, chatId: ChatId?): LinkCard? { + if (this !is LinkCard.Web || preview == null || preview.url != url) return this + val rendition = preview.image?.let { it.rendition(MediaItemRendition.Role.ORIGINAL) ?: it.renditions.firstOrNull() } + val photo = if (rendition != null && chatId != null) { + ChatPhoto(chatId, rendition, message.senderId, sealed = message.encryption != null) + } else { + null + } + return copy( + state = LinkCard.Web.State.Resolved( + title = preview.title, + description = preview.description.ifEmpty { null }, + imageUrl = null, + host = Uri.parse(url).host.orEmpty().lowercase().removePrefix("www."), + photo = photo, + ), + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt index 82c16f728..aee507ecc 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 @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.link +import com.flipcash.shared.chat.ui.linkPreview import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage @@ -53,6 +54,8 @@ internal class MessageLinkPrefetcher( .asSequence() .filterNot { it.redacted } .flatMap { it.content.asSequence() } + // The sender built this one's preview; the reader fetches nothing for it. + .filter { it.linkPreview() == null } .mapNotNull { content -> content.linkableText()?.let { classifier.firstCard(detectUrls(it)) } } .mapNotNull { card -> lookup(card, webAllowed) } .toList() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt index 26d21d8fa..59fbd814a 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.link +import com.flipcash.services.models.chat.LinkPreview import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.Router import com.flipcash.libs.coroutines.TestDispatcherProvider @@ -159,6 +160,20 @@ class MessageLinkPrefetcherTest { assertTrue(memory.webs.isEmpty()) } + @Test + fun `a web link whose sender built its preview is not fetched`() = runTest { + val memory = LinkCardMemory() + var asked = 0 + val sent = message(pageText).copy( + content = listOf(MessageContent.Text(pageText, linkPreview = LinkPreview(pageUrl, "An article", "", null))), + ) + prefetcher(memory, web = { asked++; Result.success(page) }) + .prefetch(listOf(sent), wait = 1.seconds, webLinks = true) + + assertEquals(0, asked) + assertTrue(memory.webs.isEmpty()) + } + @Test fun `a web link is fetched once when allowed, and not again once held`() = runTest { val memory = LinkCardMemory() diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt index ee4ac6bcb..43db17ff7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.models +import com.flipcash.shared.chat.media.ChatPhoto import com.flipcash.services.models.UserProfile import com.flipcash.services.models.asHandle import com.flipcash.services.models.chat.ChatId @@ -249,12 +250,16 @@ sealed interface LinkCard { /** The host answered with nothing to show. Cached; draws nothing. */ data object None : State - /** [host] comes from the URL after redirects, never from the page. */ + /** + * [host] comes from the URL after redirects, never from the page. A preview the sender + * built has its image as [photo], a chat photo, and no [imageUrl]. + */ data class Resolved( val title: String, val description: String?, val imageUrl: String?, val host: String, + val photo: ChatPhoto? = null, ) : State } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt index 91e12884e..bf79bfefc 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.ui +import com.flipcash.shared.chat.media.ChatPhoto import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -91,6 +92,17 @@ internal class WebLinkCardState( internal fun rememberWebLinkCard(card: LinkCard.Web): WebLinkCardState { val mode = LocalWebLinkPreviewMode.current var asked by rememberSaveable(card.url) { mutableStateOf(false) } + // A preview the sender built comes resolved, and drawing it contacts no site, so it needs no tap. + (card.state as? LinkCard.Web.State.Resolved)?.let { built -> + return WebLinkCardState( + url = card.url, + resolved = built.takeIf { mode != WebLinkPreviewMode.Off }, + chipHost = null, + loading = false, + host = chipHostOf(card.url), + ask = {}, + ) + } val fetching = mode == WebLinkPreviewMode.Automatic || (mode == WebLinkPreviewMode.TapToLoad && asked) val live = rememberResolvedCard( card = card, @@ -192,7 +204,7 @@ private fun WebPreview( hapticFeedbackEnabled = false, ), ) { - PreviewImage(preview.imageUrl) + PreviewImage(preview) Column( modifier = Modifier.padding(CodeTheme.dimens.staticGrid.x2), ) { @@ -288,8 +300,34 @@ private fun WebPlaceholder( * loader keeps cookies and follows any redirect. A picture that fails to load takes its slot with * it, so the card closes up around the text rather than leaving a hole. */ +/** + * The image of a preview the sender built: a chat photo, sealed for the chat when the message is, + * drawn through the app's own loader rather than the web one. A failure closes the slot as above. + */ @Composable -private fun PreviewImage(imageUrl: String?) { +private fun SenderPreviewImage(photo: ChatPhoto) { + var failed by remember(photo) { mutableStateOf(false) } + if (failed) return + AsyncImage( + model = photo, + contentDescription = null, + contentScale = ContentScale.Crop, + onError = { failed = true }, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(IMAGE_ASPECT) + .background(CodeTheme.colors.textSecondary.copy(alpha = 0.15f)) + .testTag(WEB_LINK_IMAGE_TAG), + ) +} + +@Composable +private fun PreviewImage(preview: LinkCard.Web.State.Resolved) { + preview.photo?.let { photo -> + SenderPreviewImage(photo) + return + } + val imageUrl = preview.imageUrl val loader = LocalWebPreviewImageLoader.current ?: return // The loader handles file and content addresses too, and the address came from a page. if (imageUrl == null || !imageUrl.startsWith("https://")) return diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt index c32ea1f92..dbda333c2 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt @@ -80,6 +80,7 @@ class WebLinkCardTest { uriHandler: UriHandler = RecordingUriHandler(), imageLoader: ImageLoader? = null, onLongClick: (() -> Unit)? = null, + seed: LinkCard.Web = card(), ) { composeTestRule.setContent { DesignSystem { @@ -89,15 +90,15 @@ class WebLinkCardTest { LocalWebPreviewImageLoader provides imageLoader, LocalUriHandler provides uriHandler, ) { - Card(onLongClick) + Card(onLongClick, seed) } } } } @Composable - private fun Card(onLongClick: (() -> Unit)?) { - val state = rememberWebLinkCard(card()) + private fun Card(onLongClick: (() -> Unit)?, seed: LinkCard.Web = card()) { + val state = rememberWebLinkCard(seed) WebLinkCard(state = state, onLongClick = onLongClick, onDoubleClick = null) } @@ -118,6 +119,27 @@ class WebLinkCardTest { composeTestRule.onAllNodesWithText("og:site_name").assertCountEquals(0) } + @Test + fun `a preview the sender built draws outside the group with no tap and nothing asked`() { + val resolution = resolving(LinkCard.Web.State.None) + setCard(resolution, mode = WebLinkPreviewMode.TapToLoad, seed = card(resolved)) + + composeTestRule.waitForIdle() + assertEquals(0, resolution.calls.get()) + composeTestRule.onNodeWithText("The title").assertIsDisplayed() + composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) + } + + @Test + fun `with previews off a preview the sender built draws nothing`() { + val resolution = resolving(resolved) + setCard(resolution, mode = WebLinkPreviewMode.Off, seed = card(resolved)) + + composeTestRule.waitForIdle() + assertEquals(0, resolution.calls.get()) + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) + } + @Test fun `an empty answer draws nothing`() { setCard(resolving(LinkCard.Web.State.None)) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt index b6e2021fd..a834348ee 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.ui +import com.flipcash.services.models.chat.LinkPreview import android.util.Patterns import com.flipcash.services.models.chat.MessageContent @@ -164,6 +165,13 @@ private fun endsOnAsciiBoundary(text: String, end: Int): Boolean = * * Shared with the chat mapper so a card can only ever be offered for text the reader is looking at. */ +/** The preview the sender built for this content's text, from the same text [linkableText] reads. */ +fun MessageContent.linkPreview(): LinkPreview? = when (this) { + is MessageContent.Text -> linkPreview + is MessageContent.Reply -> content.filterIsInstance().firstOrNull()?.linkPreview + else -> null +} + fun MessageContent.linkableText(): String? = when (this) { is MessageContent.Text -> text is MessageContent.Reply -> content.filterIsInstance().firstOrNull()?.text From b7b37fdcb9ac4ef1109dca3d9cdffed1a62b8d3d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 21:22:06 -0400 Subject: [PATCH 07/10] feat(messenger): show the draft's link preview above the composer field The card shows the host while the page loads and a spinner over the image while it uploads; a failed upload drops the image. The x removes the preview while that link stays in the draft, and changing the link brings one back. Photos with a ready preview go out without a caption, followed by the text as its own message carrying the preview. --- .../core/src/main/res/values/strings.xml | 3 + .../app/messenger/internal/ChatViewModel.kt | 33 ++- .../internal/link/ComposerLinkPreview.kt | 131 ++++++++++-- .../screens/components/ChatBottomBar.kt | 49 ++++- .../screens/components/ComposerLinkCard.kt | 191 ++++++++++++++++++ .../internal/link/ComposerLinkPreviewTest.kt | 165 +++++++++++++++ 6 files changed, 537 insertions(+), 35 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a4d06fc0e..525d3c621 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1554,6 +1554,9 @@ Share Share photo Remove photo + Remove link preview + Loading preview for %1$s + Preview for %1$s: %2$s Retry photo upload Photo can\'t be sent Photo upload progress diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 47519decb..a0684f2f2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -138,6 +138,7 @@ import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.models.splitAroundLinkCard import coil3.ImageLoader import com.flipcash.app.messenger.internal.link.ComposerLinkPreview +import com.flipcash.app.messenger.internal.link.ComposerLinkPreviewState import com.flipcash.app.messenger.internal.link.WebPreviewImages import com.flipcash.shared.chat.reactions.ReactionError import com.flipcash.shared.chat.reactions.ReactionPill @@ -402,6 +403,8 @@ internal class ChatViewModel @AssistedInject constructor( val replyingTo: ChatQuote? = null, /** Photos staged in the composer, in the order they were added. Hidden, not dropped, while editing. */ val stagedPhotos: List = emptyList(), + /** The draft link's preview card, or null when the draft has no previewable link. */ + val composerLink: ComposerLinkPreviewState? = null, /** Where each staged photo's upload is; a chip with no entry yet is still being picked up. */ val uploadStates: Map = emptyMap(), /** @@ -884,6 +887,10 @@ internal class ChatViewModel @AssistedInject constructor( /** The shot for [uri] was written, or, when not [saved], failed and leaves the composer. */ data class CaptureFinished(val uri: Uri, val saved: Boolean) : Event data class RemovePhoto(val id: String) : Event + data class ComposerLinkChanged(val link: ComposerLinkPreviewState?) : Event + + /** The × on the draft link's preview card. */ + data object RemoveLinkPreview : Event data class RetryPhoto(val id: String) : Event data class UploadStatesChanged(val states: Map) : Event @@ -3121,6 +3128,12 @@ internal class ChatViewModel @AssistedInject constructor( } } .launchIn(viewModelScope) + preview.state + .onEach { dispatchEvent(Event.ComposerLinkChanged(it)) } + .launchIn(viewModelScope) + eventFlow.filterIsInstance() + .onEach { preview.dismiss() } + .launchIn(viewModelScope) } // Send text message @@ -3135,14 +3148,8 @@ internal class ChatViewModel @AssistedInject constructor( // both are the composer emptying itself once the message is on its way. val replyToMessageId = stateFlow.value.replyingTo?.messageId - // Taken before the draft empties, which would otherwise drop it. A photo message - // carries no link preview. - val linkPreview = if (photos.isEmpty()) { - composerLinkPreview?.take(textToSend) - } else { - composerLinkPreview?.clear() - null - } + // Taken before the draft empties, which would otherwise drop it. + val linkPreview = composerLinkPreview?.take(textToSend) stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("") if (replyToMessageId != null) dispatchEvent(Event.CancelReply) @@ -3162,9 +3169,15 @@ internal class ChatViewModel @AssistedInject constructor( replyToMessageId, linkPreview = linkPreview, ) - } else { + } else if (linkPreview == null) { chatCoordinator.sendMedia(chatId, photos.map { it.id }, textToSend.trim(), replyToMessageId) .onFailure { dispatchEvent(Event.PhotosRestored(photos)) } + } else { + // A caption carries no preview, so the text follows the photos as its own + // message. The reply goes on the photos, and the text goes either way. + chatCoordinator.sendMedia(chatId, photos.map { it.id }, "", replyToMessageId) + .onFailure { dispatchEvent(Event.PhotosRestored(photos)) } + chatCoordinator.sendMessage(chatId, textToSend, null, linkPreview = linkPreview) } sent .onSuccess { @@ -3678,6 +3691,8 @@ internal class ChatViewModel @AssistedInject constructor( ) } is Event.UploadStatesChanged -> { state -> state.copy(uploadStates = event.states) } + is Event.ComposerLinkChanged -> { state -> state.copy(composerLink = event.link) } + Event.RemoveLinkPreview -> { state -> state } is Event.PhotosSent -> { state -> state.copy(stagedPhotos = state.stagedPhotos.filterNot { it.id in event.ids }) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt index 2e3419d81..3b2fc56b7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt @@ -15,7 +15,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -26,6 +33,7 @@ import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.io.File +import java.net.URI import java.util.UUID import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @@ -37,6 +45,7 @@ import kotlin.time.Duration.Companion.milliseconds * photo as soon as the preview loads, so by send time it is usually already up. * * The staged image is not a composer chip: chips come from the composer's own staged photos. + * [state] is what the composer's card draws. */ internal class ComposerLinkPreview @Inject constructor( private val classifier: LinkCardClassifier, @@ -48,15 +57,24 @@ internal class ComposerLinkPreview @Inject constructor( private val dispatchers: DispatcherProvider, ) { private val ready = AtomicReference(null) + private val _state = MutableStateFlow(null) + val state: StateFlow = _state.asStateFlow() + + // The link the × was tapped on. It stays dismissed only while it is the draft's link, so editing + // the link brings a preview back. + private val dismissed = MutableStateFlow(null) /** Follows [draft] for [chatId] until [scope] ends, keeping at most one preview ready. */ @OptIn(FlowPreview::class) fun bind(scope: CoroutineScope, chatId: ChatId, draft: Flow) { scope.launch { try { - draft + val links = draft .debounce(DEBOUNCE) .map(::firstWebUrl) + .distinctUntilChanged() + .onEach { url -> if (url != dismissed.value) dismissed.value = null } + combine(links, dismissed) { url, gone -> url?.takeIf { it != gone } } .distinctUntilChanged() .collectLatest { url -> clear() @@ -73,7 +91,9 @@ internal class ComposerLinkPreview @Inject constructor( * no longer in the text; the send makes the authoritative whole-link check on the wire text. */ fun take(text: String): OutgoingLinkPreview? { - val preview = ready.getAndSet(null) ?: return null + val preview = ready.getAndSet(null) + dropCard() + if (preview == null) return null if (text.contains(preview.url)) return preview preview.imageUploadId?.let(images::discard) return null @@ -82,19 +102,51 @@ internal class ComposerLinkPreview @Inject constructor( /** Drops the ready preview and its staged image, for a draft that won't send it. */ fun clear() { ready.getAndSet(null)?.imageUploadId?.let(images::discard) + dropCard() + } + + /** The ×: removes the preview while its link stays in the draft. */ + fun dismiss() { + val url = _state.value?.url ?: return + clear() + dismissed.value = url + } + + private fun dropCard() { + _state.value = null } private fun firstWebUrl(text: String): String? = (classifier.firstCard(detectUrls(text)) as? LinkCard.Web)?.url private suspend fun prepare(scope: CoroutineScope, chatId: ChatId, url: String) { - val page = lookup(url).getOrNull() as? LinkCard.Web.State.Resolved ?: return - val title = LinkPreviewChecks.cleanTitle(page.title) ?: return + val host = hostOf(url) + _state.value = ComposerLinkPreviewState.Loading(url, host) + val page = lookup(url).getOrNull() as? LinkCard.Web.State.Resolved + val title = page?.let { LinkPreviewChecks.cleanTitle(it.title) } + if (page == null || title == null) { + _state.value = null + return + } val description = LinkPreviewChecks.cleanDescription(page.description.orEmpty()) var imageId: String? = null try { - imageId = page.imageUrl?.let { stageImage(scope, chatId, it) } + val file = page.imageUrl?.let { download(it) } + imageId = file?.let { uploads.stage(chatId, Uri.fromFile(it)) } ready.set(OutgoingLinkPreview(url, title, description, imageId)) + _state.value = ComposerLinkPreviewState.Ready( + url = url, + host = host, + title = title, + description = description.ifEmpty { null }, + image = file?.takeIf { imageId != null }, + imageUploading = imageId != null, + ) + if (file != null && imageId != null) { + val id = imageId + scope.launch { followUpload(id) } + scope.launch { deleteWhenUnused(id, file) } + } } catch (e: CancellationException) { // The draft moved on while the image was staging; nothing will send it. imageId?.let(images::discard) @@ -102,19 +154,39 @@ internal class ComposerLinkPreview @Inject constructor( } } - /** Downloads [imageUrl] and stages it; null if the download fails. */ - private suspend fun stageImage(scope: CoroutineScope, chatId: ChatId, imageUrl: String): String? { - val file = download(imageUrl) ?: return null - val id = uploads.stage(chatId, Uri.fromFile(file)) - // Past preparing, the uploads hold their own encoded copy. - scope.launch { - try { - uploads.state(id).first { it !is ChatMediaUploadState.Preparing } - } finally { - file.delete() + /** Clears the card's spinner once [id] is up, and drops the image if its upload fails. */ + private suspend fun followUpload(id: String) { + val settled = uploads.state(id).first { + it == null || it is ChatMediaUploadState.Uploaded || it is ChatMediaUploadState.Processing || + it is ChatMediaUploadState.Failed + } + val failed = settled == null || settled is ChatMediaUploadState.Failed + if (failed) { + // Only while the draft still owns the image: once taken, the send handles its failure. + val owned = ready.get()?.takeIf { it.imageUploadId == id } + if (owned != null && ready.compareAndSet(owned, owned.copy(imageUploadId = null))) { + images.discard(id) } } - return id + _state.update { current -> + (current as? ComposerLinkPreviewState.Ready) + ?.takeIf { it.imageUploading } + ?.copy(imageUploading = false, image = current.image.takeUnless { failed }) + ?: current + } + } + + /** + * Deletes the downloaded [file] once the upload holds its own encoded copy, which it does past + * preparing, and the card no longer draws it. + */ + private suspend fun deleteWhenUnused(id: String, file: File) { + try { + uploads.state(id).first { it !is ChatMediaUploadState.Preparing } + _state.first { (it as? ComposerLinkPreviewState.Ready)?.image != file } + } finally { + withContext(NonCancellable) { file.delete() } + } } private suspend fun download(imageUrl: String): File? = withContext(dispatchers.IO) { @@ -132,8 +204,31 @@ internal class ComposerLinkPreview @Inject constructor( private fun directory() = File(context.cacheDir, DIRECTORY).apply { mkdirs() } - private companion object { + internal companion object { val DEBOUNCE = 400.milliseconds - const val DIRECTORY = "link-preview-images" + private const val DIRECTORY = "link-preview-images" } } + +/** What the composer's link card shows. */ +internal sealed interface ComposerLinkPreviewState { + val url: String + val host: String + + /** The page is being fetched. */ + data class Loading(override val url: String, override val host: String) : ComposerLinkPreviewState + + /** The preview as the recipient will see it. [image] is a local file, absent when the page has none or its upload failed. */ + data class Ready( + override val url: String, + override val host: String, + val title: String, + val description: String?, + val image: File?, + val imageUploading: Boolean, + ) : ComposerLinkPreviewState +} + +/** The link's host without a leading `www.`, as the card's first line shows it. */ +internal fun hostOf(url: String): String = + (runCatching { URI(url).host }.getOrNull() ?: url).removePrefix("www.") diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index cb3dd2e2e..c0c380d13 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -24,6 +24,12 @@ import com.flipcash.shared.chat.ui.media.composerImageReceiver import com.flipcash.shared.chat.ui.media.deleteLeftoverCaptures import androidx.compose.ui.platform.LocalContext import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeOut +import androidx.compose.animation.fadeIn +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -418,6 +424,12 @@ internal fun UserControlBottomBar( .composerSidePadding(composerInsets, hasComposer), ) { if (canType) { + // The last card shown, kept while it animates out after the preview goes. + val linkCardVisible = remember { MutableTransitionState(false) } + var linkCard by remember { mutableStateOf(state.composerLink) } + state.composerLink?.let { linkCard = it } + linkCardVisible.targetState = state.composerLink != null + val showsLinkCard = linkCardVisible.currentState || linkCardVisible.targetState val chips = remember(state.composerPhotos, state.uploadStates) { state.composerPhotos.map { photo -> ComposerPhotoChip( @@ -537,15 +549,36 @@ internal fun UserControlBottomBar( } } } else null, - header = if (chips.isNotEmpty()) { + header = if (chips.isNotEmpty() || showsLinkCard) { { - ComposerPhotoChips( - chips = chips, - onRemove = { dispatch(ChatViewModel.Event.RemovePhoto(it)) }, - onRetry = { dispatch(ChatViewModel.Event.RetryPhoto(it)) }, - modifier = Modifier, - newestChipModifier = Modifier.onGloballyPositioned { newestChipCoordinates = it }, - ) + Column { + if (chips.isNotEmpty()) { + ComposerPhotoChips( + chips = chips, + onRemove = { dispatch(ChatViewModel.Event.RemovePhoto(it)) }, + onRetry = { dispatch(ChatViewModel.Event.RetryPhoto(it)) }, + modifier = Modifier, + newestChipModifier = Modifier.onGloballyPositioned { newestChipCoordinates = it }, + ) + } + // Under the photos, as on iOS, inset like the first chip. + AnimatedVisibility( + visibleState = linkCardVisible, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), + ) { + linkCard?.let { card -> + ComposerLinkCard( + state = card, + onRemove = { dispatch(ChatViewModel.Event.RemoveLinkPreview) }, + modifier = Modifier.padding( + start = CodeTheme.dimens.staticGrid.x3, + top = if (chips.isNotEmpty()) CodeTheme.dimens.staticGrid.x2 else 0.dp, + ), + ) + } + } + } } } else null, // One read of the edit state decides both the glyph and what the tap diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt new file mode 100644 index 000000000..3253bed53 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt @@ -0,0 +1,191 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Link +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.flipcash.app.messenger.internal.link.ComposerLinkPreviewState +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeCircularProgressIndicator + +internal const val COMPOSER_LINK_CARD_TAG = "composer_link_card" + +/** + * The draft link's preview as the recipient will see it (iOS `ComposerLinkChip`): the page's image, + * host, description and title, a spinner while the page loads or its image uploads, and a remove + * button hanging off the card's top-end corner. + */ +@Composable +internal fun ComposerLinkCard( + state: ComposerLinkPreviewState, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + val grid = CodeTheme.dimens.staticGrid + val label = when (state) { + is ComposerLinkPreviewState.Loading -> + stringResource(R.string.description_chatLinkPreviewLoading, state.host) + is ComposerLinkPreviewState.Ready -> + stringResource(R.string.description_chatLinkPreview, state.host, state.title) + } + // The remove button hangs past the card's corner, so the card is inset by that much. + Box(modifier.fillMaxWidth()) { + Box(Modifier.testTag(COMPOSER_LINK_CARD_TAG)) { + Row( + modifier = Modifier + .padding(top = RemoveOverhang, end = RemoveOverhang) + .widthIn(max = MaxCardWidth) + .background(Color.Black.copy(alpha = 0.25f), RoundedCornerShape(CardCorner)) + .padding(grid.x3) + .semantics { contentDescription = label }, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Thumbnail(state) + Column( + modifier = Modifier.weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + when (state) { + is ComposerLinkPreviewState.Loading -> HostText(state.host) + is ComposerLinkPreviewState.Ready -> { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + HostText(state.host) + state.description?.let { + Text( + text = it, + modifier = Modifier.weight(1f, fill = false), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = state.title, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + RemoveButton(onRemove, Modifier.align(Alignment.TopEnd)) + } + } +} + +@Composable +private fun HostText(host: String) { + Text( + text = host, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun Thumbnail(state: ComposerLinkPreviewState) { + val grid = CodeTheme.dimens.staticGrid + Box( + modifier = Modifier + .size(ImageSize) + .clip(RoundedCornerShape(ImageCorner)) + .background(CodeTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + when { + state is ComposerLinkPreviewState.Loading -> Spinner() + state is ComposerLinkPreviewState.Ready && state.image != null -> { + AsyncImage( + model = state.image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(ImageSize), + ) + if (state.imageUploading) { + Box(Modifier.size(ImageSize).background(Color.Black.copy(alpha = 0.35f))) + Spinner() + } + } + else -> Icon( + imageVector = Icons.Filled.Link, + contentDescription = null, + tint = Color.White.copy(alpha = 0.6f), + modifier = Modifier.size(grid.x5), + ) + } + } +} + +@Composable +private fun Spinner() { + CodeCircularProgressIndicator( + strokeWidth = CodeTheme.dimens.thickBorder, + color = Color.White, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) +} + +@Composable +private fun RemoveButton(onRemove: () -> Unit, modifier: Modifier) { + val grid = CodeTheme.dimens.staticGrid + // Centred on the card's top-end corner, which sits RemoveOverhang in from this box's. + Box( + modifier = modifier + .offset(x = RemoveSize / 2 - RemoveOverhang, y = RemoveOverhang - RemoveSize / 2) + .size(RemoveSize) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.55f)) + .clickable(role = Role.Button, onClick = onRemove), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.description_chatLinkPreviewRemove), + tint = Color.White, + modifier = Modifier.size(grid.x2), + ) + } +} + +private val ImageSize = 44.dp +private val ImageCorner = 8.dp +private val CardCorner = 14.dp +private val MaxCardWidth = 280.dp +private val RemoveOverhang = 9.dp +private val RemoveSize = 18.dp diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt new file mode 100644 index 000000000..2763078fb --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt @@ -0,0 +1,165 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.ui.DetectedUrl +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * The composer's preview follows the draft's first web link, and the × holds only for that link. + * + * Runs on real dispatchers: the page comes back on OkHttp's thread, which virtual time can't wait on. + */ +@RunWith(RobolectricTestRunner::class) +class ComposerLinkPreviewTest { + + private val chatId = ChatId(UUID.randomUUID().toString().toByteArray().take(16)) + + private val dispatchers = object : DispatcherProvider { + override val Default: CoroutineDispatcher = Dispatchers.Default + override val Main: CoroutineDispatcher = Dispatchers.Default + override val IO: CoroutineDispatcher = Dispatchers.IO + } + + // Every detected link is a web card. + private val classifier = mock { + on { firstCard(any()) } doAnswer { call -> + @Suppress("UNCHECKED_CAST") + (call.arguments[0] as List).firstOrNull()?.let { LinkCard.Web(it.url, it.start, it.end) } + } + } + + // Every page has a title naming its url, and no image. + private val pages = OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request() + val html = "" + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(html.toResponseBody("text/html; charset=utf-8".toMediaType())) + .build() + } + .build() + + private fun withPreview(draft: MutableStateFlow, block: suspend (ComposerLinkPreview) -> Unit) = + runBlocking { + val scope = CoroutineScope(Dispatchers.Default) + val preview = ComposerLinkPreview( + classifier = classifier, + lookup = WebLinkLookup(pages, enabled = { true }, dispatchers = dispatchers), + imageClient = mock(), + uploads = mock(), + images = mock(), + context = RuntimeEnvironment.getApplication(), + dispatchers = dispatchers, + ) + try { + preview.bind(scope, chatId, draft) + block(preview) + } finally { + scope.cancel() + } + } + + private suspend fun ComposerLinkPreview.awaitCard(url: String): ComposerLinkPreviewState.Ready = + withTimeout(5.seconds) { + state.first { it is ComposerLinkPreviewState.Ready && it.url == url } as ComposerLinkPreviewState.Ready + } + + /** Long enough for a draft change to pass the debounce and settle. */ + private suspend fun settle() = delay(ComposerLinkPreview.DEBOUNCE + 300.milliseconds) + + @Test + fun `a draft link shows a ready preview`() = withPreview(MutableStateFlow("look https://example.com/a")) { preview -> + val card = preview.awaitCard("https://example.com/a") + + assertEquals("example.com", card.host) + assertEquals("Title of https://example.com/a", card.title) + } + + @Test + fun `removing keeps the preview off while the link stays`() { + val draft = MutableStateFlow("look https://example.com/a") + withPreview(draft) { preview -> + preview.awaitCard("https://example.com/a") + + preview.dismiss() + draft.value = "look https://example.com/a and more" + settle() + + assertNull(preview.state.value) + assertNull(preview.take(draft.value)) + } + } + + @Test + fun `editing the removed link brings a preview back`() { + val draft = MutableStateFlow("look https://example.com/a") + withPreview(draft) { preview -> + preview.awaitCard("https://example.com/a") + + preview.dismiss() + draft.value = "look https://example.com/b" + + preview.awaitCard("https://example.com/b") + } + } + + @Test + fun `typing the removed link again after deleting it brings it back`() { + val draft = MutableStateFlow("look https://example.com/a") + withPreview(draft) { preview -> + preview.awaitCard("https://example.com/a") + + preview.dismiss() + draft.value = "look" + settle() + draft.value = "look https://example.com/a" + + preview.awaitCard("https://example.com/a") + } + } + + @Test + fun `take hands over the ready preview and drops the card`() { + val draft = MutableStateFlow("look https://example.com/a") + withPreview(draft) { preview -> + preview.awaitCard("https://example.com/a") + + val taken = preview.take(draft.value) + + assertEquals("https://example.com/a", taken?.url) + assertEquals("Title of https://example.com/a", taken?.title) + assertNull(preview.state.value) + } + } +} From 80730b7a284e3b23f72be8c5832198aa37a05efe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 21:38:24 -0400 Subject: [PATCH 08/10] refactor(messenger): drop the receive-side web preview fetch Recipients draw a web card only from the preview its sender attached, so the page prefetch, the web answers in the link card cache, the web image store and the web preview flag go. The cache keeps group, token and person answers; web rows already on disk fail to decode and are deleted on load. --- .../core/src/main/res/values/strings.xml | 2 - .../features/messenger/build.gradle.kts | 3 - .../app/messenger/internal/ChatViewModel.kt | 15 +- .../internal/link/CoilWebImageStore.kt | 10 - .../messenger/internal/link/LinkCardMemory.kt | 43 --- .../internal/link/LinkCardMemoryModule.kt | 97 +----- .../messenger/internal/link/LinkCardModule.kt | 2 - .../internal/link/LinkCardResolver.kt | 31 +- .../internal/link/MessageLinkPrefetcher.kt | 96 ------ .../internal/link/PersistedLinkCardMemory.kt | 76 +---- .../messenger/internal/link/WebImageClient.kt | 9 +- .../messenger/internal/link/WebImageLoader.kt | 69 ---- .../messenger/internal/link/WebImageStore.kt | 10 - .../messenger/internal/link/WebLinkLookup.kt | 10 +- .../internal/link/WebPreviewModule.kt | 44 +++ .../internal/screens/MessengerScreen.kt | 3 - .../screens/components/MessageList.kt | 14 - .../internal/link/ComposerLinkPreviewTest.kt | 2 +- .../internal/link/LinkCardResolverTest.kt | 107 +----- .../internal/link/LookupVectorTest.kt | 52 +-- .../link/MessageLinkPrefetcherTest.kt | 222 ------------ .../link/PersistedLinkCardMemoryTest.kt | 255 -------------- .../internal/link/WebImageClientTest.kt | 15 +- .../internal/link/WebImageLoaderTest.kt | 114 ------- .../internal/link/WebLinkLookupTest.kt | 26 +- .../shared/chat/models/WebLinkPreview.kt | 22 -- .../flipcash/shared/chat/ui/LinkCardView.kt | 9 +- .../flipcash/shared/chat/ui/MessageBubble.kt | 14 +- .../flipcash/shared/chat/ui/WebLinkCard.kt | 230 +------------ .../shared/chat/ui/WebBareCardTest.kt | 315 +----------------- .../chat/ui/WebCardImageFirstFrameTest.kt | 102 ------ .../shared/chat/ui/WebCardOutlineTest.kt | 22 +- .../shared/chat/ui/WebLinkCardTest.kt | 269 +-------------- .../shared/chat/MessageLinkPrefetch.kt | 50 --- .../shared/chat/internal/WebPreviewGate.kt | 27 -- .../internal/delegates/EventStreamDelegate.kt | 7 - .../internal/delegates/FeedSyncDelegate.kt | 9 +- .../internal/delegates/GroupFeedDelegate.kt | 5 - .../internal/delegates/MessagingDelegate.kt | 8 - .../shared/chat/MessagingWebPrefetchTest.kt | 84 ----- .../shared/chat/WebPreviewGateTest.kt | 77 ----- .../flipcash/app/featureflags/FeatureFlag.kt | 13 - 42 files changed, 149 insertions(+), 2441 deletions(-) delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CoilWebImageStore.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageStore.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPreviewModule.kt delete mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt delete mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoaderTest.kt delete mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt delete mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardImageFirstFrameTest.kt delete mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt delete mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt delete mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingWebPrefetchTest.kt delete mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/WebPreviewGateTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 525d3c621..bc839161b 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1052,8 +1052,6 @@ Group Chat View - - Show preview · %1$s Group Unavailable diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index ed4cfa591..1c21aec92 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -45,9 +45,6 @@ dependencies { implementation(libs.kotlinx.serialization.json) // HttpUrl parses and normalises outside links (WebLinks). implementation(libs.okhttp) - // The preview picture's own ImageLoader (WebImageLoader), on the client built under the page rules. - implementation(libs.coil3.core) - implementation(libs.coil3.network) testImplementation(libs.bundles.unit.testing) testImplementation(libs.mockito.kotlin) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index a0684f2f2..ee9ce1c66 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -22,8 +22,6 @@ import androidx.paging.cachedIn import androidx.paging.flatMap import androidx.paging.map import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.featureflags.NoOpFeatureFlagController import com.flipcash.analytics.CashLinkChoice import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess import com.flipcash.analytics.GroupGateFunding @@ -136,10 +134,8 @@ import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SenderIdentity import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.models.splitAroundLinkCard -import coil3.ImageLoader import com.flipcash.app.messenger.internal.link.ComposerLinkPreview import com.flipcash.app.messenger.internal.link.ComposerLinkPreviewState -import com.flipcash.app.messenger.internal.link.WebPreviewImages import com.flipcash.shared.chat.reactions.ReactionError import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.ReactionStrip @@ -282,10 +278,8 @@ internal class ChatViewModel @AssistedInject constructor( private val hostLobbies: HostLobbies, private val groupRosters: GroupRosters, private val dispatchers: DispatcherProvider, - // Last and defaulted so the transcript tests, which draw no pictures, need not supply one. - @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, + // Last and defaulted so the transcript tests, which hold no saved previews, need not supply one. linkCardMemory: LinkCardMemory? = null, - featureFlags: FeatureFlagController = NoOpFeatureFlagController, private val composerLinkPreview: ComposerLinkPreview? = null, ) : BaseViewModel( initialState = State(), @@ -293,12 +287,6 @@ internal class ChatViewModel @AssistedInject constructor( defaultDispatcher = dispatchers.Default, ) { - /** - * Whether web link previews are on. Observed rather than read once, because the flag can flip - * in the staff menu while a chat is open. Off means no web card is drawn at all. - */ - val webLinkPreviewsEnabled: StateFlow = featureFlags.observe(FeatureFlag.WebLinkPreviews) - /** * Whether the transcript may be drawn. Saved link previews load at app start, but a chat opened * straight from a notification can beat them to it, and a card drawn before its saved answer @@ -3505,7 +3493,6 @@ internal class ChatViewModel @AssistedInject constructor( /** The longest the first draw of a transcript waits for saved link previews to load. */ val PREVIEWS_WAIT = 300.milliseconds - /** * How often a visible claimable voucher is re-asked about. * diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CoilWebImageStore.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CoilWebImageStore.kt deleted file mode 100644 index b7a3881e0..000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/CoilWebImageStore.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.flipcash.app.messenger.internal.link - -import coil3.disk.DiskCache - -/** [WebImageStore] over the preview loader's [DiskCache]. */ -internal class CoilWebImageStore(private val diskCache: DiskCache?) : WebImageStore { - override fun remove(url: String) { - diskCache?.remove(url) - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt index fb223a855..04b222f4b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt @@ -33,45 +33,6 @@ internal open class LinkCardMemory( val groups: Map get() = _groups val users: Map get() = _users - /** - * Web page answers by [WebLinks.cacheKey]. Only answers: a failed lookup is never put here. An - * answer past its TTL reads as absent (parity decision D19), so it is looked up again. - */ - val webs: Map = FreshWebs() - - private class Stored(val state: LinkCard.Web.State, val at: Long) - - private val _webEntries = ConcurrentHashMap() - - private fun Stored.isFresh(): Boolean { - val ttl = if (state is LinkCard.Web.State.Resolved) WebLinks.RESOLVED_TTL else WebLinks.EMPTY_TTL - return clock() - at <= ttl.inWholeMilliseconds - } - - private inner class FreshWebs : AbstractMap() { - override val entries: Set> - get() = _webEntries.entries.filter { it.value.isFresh() } - .associate { it.key to it.value.state }.entries - - override fun get(key: String): LinkCard.Web.State? = - _webEntries[key]?.takeIf { it.isFresh() }?.state - - override fun containsKey(key: String): Boolean = get(key) != null - } - - /** Holds [state] as of [at]: now for an answer just fetched, the row's `updatedAt` for a loaded one. */ - protected fun storeWeb(key: String, state: LinkCard.Web.State, at: Long = clock()) { - _webEntries[key] = Stored(state, at) - } - - /** The held answer for [key] whatever its age: an expired one still names a picture on disk. */ - protected fun heldWeb(key: String): LinkCard.Web.State? = _webEntries[key]?.state - - /** Every held answer whatever its age, by key. */ - protected fun heldWebs(): Map = _webEntries.mapValues { it.value.state } - - protected fun clearWebs() = _webEntries.clear() - protected val _tokens = ConcurrentHashMap() protected val _groups = ConcurrentHashMap() protected val _users = ConcurrentHashMap() @@ -80,10 +41,6 @@ internal open class LinkCardMemory( _tokens[mint] = state } - open fun putWeb(key: String, state: LinkCard.Web.State) { - storeWeb(key, state) - } - open fun putGroup(chatId: ChatId, state: LinkCard.GroupInvite.State.Resolved) { _groups[chatId] = state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt index 2989f2b8b..6fa65ff6d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemoryModule.kt @@ -1,39 +1,19 @@ package com.flipcash.app.messenger.internal.link -import android.content.Context -import coil3.ImageLoader -import coil3.disk.DiskCache -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.sources.LinkPreviewDataSource import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.MessageLinkPrefetch import com.getcode.util.resources.ResourceHelper import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import kotlinx.coroutines.runBlocking -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import okhttp3.OkHttpClient -import javax.inject.Qualifier import javax.inject.Singleton -/** The client for fetching outside pages: no cookies, public addresses only, bodies not logged. */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -internal annotation class WebPreview - -/** The client for the picture a page names: the page rules plus a cap on the body, see [webImageClient]. */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -internal annotation class WebPreviewImages - /** - * The process-wide half of link cards: the answers kept between visits, and the prefetch that - * fills them as messages arrive. The per-visit half is [LinkCardModule]. + * The process-wide half of link cards: the answers kept between visits. The per-visit half is + * [LinkCardModule]. */ @Module @InstallIn(SingletonComponent::class) @@ -42,9 +22,6 @@ internal abstract class LinkCardMemoryModule { @Binds abstract fun bindLinkCardMemory(impl: PersistedLinkCardMemory): LinkCardMemory - @Binds - abstract fun bindMessageLinkPrefetch(impl: MessageLinkPrefetcher): MessageLinkPrefetch - companion object { @Provides @Singleton @@ -52,75 +29,7 @@ internal abstract class LinkCardMemoryModule { store: LinkPreviewDataSource, userManager: UserManager, resources: ResourceHelper, - images: WebImageStore, - dispatchers: DispatcherProvider, - ): PersistedLinkCardMemory = PersistedLinkCardMemory(store, userManager, resources, images, dispatchers) - - // Not the app's singleton client: it logs bodies at Level.BODY and keeps cookies. - @Provides - @Singleton - @WebPreview - fun provideWebPreviewClient(): OkHttpClient = webPreviewClient() - - @Provides - @Singleton - @WebPreviewImages - fun provideWebImageClient(flags: FeatureFlagController): OkHttpClient = - // The interceptor runs on OkHttp's own thread, where blocking for a flag read is fine. - webImageClient(enabled = { runBlocking { flags.get(FeatureFlag.WebLinkPreviews) } }) - - @Provides - @Singleton - @WebPreviewImages - fun provideWebPreviewDiskCache(@ApplicationContext context: Context): DiskCache = - webPreviewDiskCache(context) - - @Provides - @Singleton - @WebPreviewImages - fun provideWebPreviewImageLoader( - @ApplicationContext context: Context, - @WebPreviewImages client: OkHttpClient, - @WebPreviewImages diskCache: DiskCache, - ): ImageLoader = webPreviewImageLoader(context, client, diskCache) - - @Provides - @Singleton - fun provideWebImageStore(@WebPreviewImages diskCache: DiskCache): WebImageStore = - CoilWebImageStore(diskCache) - - @Provides - @Singleton - fun provideWebLinkLookup( - @WebPreview client: OkHttpClient, - flags: FeatureFlagController, - dispatchers: DispatcherProvider, - memory: LinkCardMemory, - ): WebLinkLookup = WebLinkLookup( - client = client, - enabled = { flags.get(FeatureFlag.WebLinkPreviews) }, - dispatchers = dispatchers, - memory = memory, - ) - - @Provides - @Singleton - fun provideMessageLinkPrefetcher( - classifier: LinkCardClassifier, - memory: LinkCardMemory, - group: GroupLinkLookup, - user: UserLinkLookup, - web: WebLinkLookup, - flags: FeatureFlagController, dispatchers: DispatcherProvider, - ): MessageLinkPrefetcher = MessageLinkPrefetcher( - classifier = classifier, - memory = memory, - group = { group(it) }, - user = { user(it) }, - web = { web(it) }, - dispatchers = dispatchers, - webEnabled = { flags.get(FeatureFlag.WebLinkPreviews) }, - ) + ): PersistedLinkCardMemory = PersistedLinkCardMemory(store, userManager, resources, dispatchers) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt index 20bcf4f94..d7d01b278 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 @@ -35,7 +35,6 @@ internal object LinkCardModule { token: TokenLinkLookup, group: GroupLinkLookup, user: UserLinkLookup, - web: WebLinkLookup, memory: LinkCardMemory, ): LinkCardResolver = LinkCardResolver( @@ -47,7 +46,6 @@ internal object LinkCardModule { tokenMetadata = { token(it) }, group = { group(it) }, user = { user(it) }, - web = web::invoke, memory = memory, ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index f0d87a0a8..1acac0d4b 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 @@ -51,9 +51,6 @@ internal class LinkCardResolver( private val tokenMetadata: suspend (mint: Mint) -> Result, private val group: suspend (chatId: ChatId) -> Result, private val user: suspend (identity: LinkCard.User.Identity) -> Result, - private val web: suspend (url: String) -> Result = { - Result.failure(IllegalStateException("no web lookup")) - }, private val memory: LinkCardMemory = LinkCardMemory(), ) : LinkCardResolution { @@ -74,7 +71,6 @@ internal class LinkCardResolver( private val tokenQueries = mutableMapOf>() private val groupQueries = mutableMapOf>() private val userQueries = mutableMapOf>() - private val webQueries = mutableMapOf>>() /** * The answers that have landed, readable without the lock and without suspending — which is the @@ -121,7 +117,8 @@ internal class LinkCardResolver( is LinkCard.TokenInfo -> tokenAnswers[card.mint]?.let { card.copy(state = it) } 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) } + // A web card is drawn from the preview its sender built, which arrives on the card itself. + is LinkCard.Web -> null } override suspend fun resolve(card: LinkCard): LinkCard = when (card) { @@ -129,7 +126,7 @@ internal class LinkCardResolver( is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint)) 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.Web -> card } /** @@ -266,28 +263,6 @@ internal class LinkCardResolver( ) } - /** - * The answer for a web page. A lookup that failed answers [LinkCard.Web.State.None] for this - * draw, so a card waiting on it stops loading and falls back to the link; that is forgotten, - * never remembered or stored, so the next draw asks again. A page that answers is held in - * [memory] by its cache key, which ignores the fragment. - */ - private suspend fun webState(url: String): LinkCard.Web.State { - val key = WebLinks.cacheKey(url) ?: return LinkCard.Web.State.None - memory.webs[key]?.let { return it } - return memoized(webQueries, key) { - web(url).also { result -> - result.onSuccess { - memory.putWeb(key, it) - _revision.update { n -> n + 1 } - } - // Memory holds a success, and expires it by TTL. Keeping the query too would - // hand back the expired answer instead of asking again. - forget(webQueries, key) - } - }.getOrDefault(LinkCard.Web.State.None) - } - private suspend fun memoized( queries: MutableMap>, key: K, 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 deleted file mode 100644 index aee507ecc..000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.flipcash.app.messenger.internal.link - -import com.flipcash.shared.chat.ui.linkPreview -import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatMessage -import com.flipcash.shared.chat.MessageLinkPrefetch -import com.flipcash.shared.chat.models.LinkCard -import com.flipcash.shared.chat.ui.detectUrls -import com.flipcash.shared.chat.ui.linkableText -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * Resolves the group and person links in arriving messages into [LinkCardMemory], so a card is - * drawn resolved on the frame its message first appears. See [MessageLinkPrefetch]. - * - * Group and person links are always fetched ahead; outside pages only when the caller passes - * `webLinks` and web link previews are on. Cash and token cards keep resolving when drawn, as before. - * - * Links are found exactly as the transcript finds them -- the same text, the same detection pass, - * the same classifier -- so a link prefetched here is the link a card is drawn for. - */ -internal class MessageLinkPrefetcher( - private val classifier: LinkCardClassifier, - private val memory: LinkCardMemory, - private val group: suspend (chatId: ChatId) -> Result, - private val user: suspend (identity: LinkCard.User.Identity) -> Result, - private val web: suspend (url: String) -> Result, - dispatchers: DispatcherProvider, - private val webEnabled: suspend () -> Boolean = { true }, -) : MessageLinkPrefetch { - - private val scope = CoroutineScope(SupervisorJob() + dispatchers.IO) - - /** In-flight lookups by link, so a link in two arriving batches is asked for once. */ - private val inFlight = ConcurrentHashMap>() - - override suspend fun prefetch(messages: List, wait: Duration, webLinks: Boolean) { - // A lookup for a link the store already answers would be wasted, and on a cold start the - // store may still be loading; wait briefly for it rather than asking for everything. - withTimeoutOrNull(LOAD_WAIT) { memory.awaitLoaded() } - - // Read once per batch: the flag can flip in the staff menu while a chat is open. - val webAllowed = webLinks && webEnabled() - val lookups = messages - .asSequence() - .filterNot { it.redacted } - .flatMap { it.content.asSequence() } - // The sender built this one's preview; the reader fetches nothing for it. - .filter { it.linkPreview() == null } - .mapNotNull { content -> content.linkableText()?.let { classifier.firstCard(detectUrls(it)) } } - .mapNotNull { card -> lookup(card, webAllowed) } - .toList() - - if (lookups.isEmpty() || wait <= Duration.ZERO) return - withTimeoutOrNull(wait) { lookups.awaitAll() } - } - - private fun lookup(card: LinkCard, webLinks: Boolean): Deferred? = when (card) { - is LinkCard.GroupInvite -> card.chatId - .takeUnless { it in memory.groups } - ?.let { chatId -> start(chatId) { group(chatId).onSuccess { memory.putGroup(chatId, it) } } } - is LinkCard.User -> card.identity - .takeUnless { it in memory.users } - ?.let { identity -> start(identity) { user(identity).onSuccess { memory.putUser(identity, it) } } } - // An outside fetch shows the linked host who is reading, so only a caller that vouched for - // the open chat and its member asks for it. Otherwise the card resolves when drawn. - 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 - } - - private fun start(key: Any, block: suspend () -> Unit): Deferred = - inFlight.computeIfAbsent(key) { - scope.async { - try { - block() - } finally { - inFlight.remove(key) - } - } - } - - private companion object { - val LOAD_WAIT = 500.milliseconds - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt index 3d4583623..48636978e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemory.kt @@ -22,10 +22,9 @@ import kotlinx.serialization.json.Json import java.util.concurrent.ConcurrentHashMap import kotlin.time.Clock import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.milliseconds /** - * [LinkCardMemory] that also keeps group, person and web page answers in `link_previews`, so the first visit + * [LinkCardMemory] that also keeps group and person answers in `link_previews`, so the first visit * after a cold start paints those cards resolved rather than growing into them. * * Only those two are stored because only those two change size when they resolve: a group card @@ -48,7 +47,6 @@ internal class PersistedLinkCardMemory( private val store: LinkPreviewDataSource, private val userManager: UserManager, private val resources: ResourceHelper, - private val images: WebImageStore, dispatchers: DispatcherProvider, private val now: () -> Long = { Clock.System.now().toEpochMilliseconds() }, ) : LinkCardMemory(clock = now) { @@ -63,18 +61,12 @@ internal class PersistedLinkCardMemory( scope.launch { store.observeAll( writtenSince = { now() - MAX_AGE.inWholeMilliseconds }, - onDropped = { dropped -> dropped.forEach { removeImageOf(it) } }, ).collect { records -> loaded.value = false - val held = heldWebs() _groups.clear() _users.clear() - clearWebs() writtenAt.clear() records.forEach { load(it) } - // A database closing or changing hands: what the last one held and this one does not. - val kept = heldWebs().values.mapNotNullTo(mutableSetOf()) { imageOf(it) } - held.values.mapNotNull { imageOf(it) }.filter { it !in kept }.forEach { removeImage(it) } loaded.value = true trace(tag = TAG, message = "Loaded ${records.size} link previews", type = TraceType.Process) } @@ -99,16 +91,6 @@ internal class PersistedLinkCardMemory( write(key, json.encodeToString(UserProfile.serializer(), state.profile.publicOnly())) } - override fun putWeb(key: String, state: LinkCard.Web.State) { - val rowKey = WEB_PREFIX + key - val unchanged = webs[key] == state - val replaced = heldWeb(key)?.let { imageOf(it) } - storeWeb(key, state) - if (replaced != null && replaced != imageOf(state)) scope.launch { removeImage(replaced) } - if (unchanged && !rewriteDue(rowKey)) return - write(rowKey, webJson.encodeToString(StoredWeb.serializer(), StoredWeb.of(state))) - } - override fun removeGroup(chatId: ChatId) { if (_groups.remove(chatId) != null) delete(groupKey(chatId)) } @@ -120,20 +102,6 @@ internal class PersistedLinkCardMemory( private fun rewriteDue(key: String): Boolean = now() - (writtenAt[key] ?: 0L) >= REWRITE_AFTER.inWholeMilliseconds - private fun imageOf(state: LinkCard.Web.State): String? = (state as? LinkCard.Web.State.Resolved)?.imageUrl - - private fun removeImage(url: String) { - runCatching { images.remove(url) } - .onFailure { trace(tag = TAG, message = "Failed to remove an image", error = it) } - } - - /** A row dropped for age by the table, which has already deleted it: only its picture is left. */ - private fun removeImageOf(record: LinkPreviewRecord) { - if (!record.key.startsWith(WEB_PREFIX)) return - runCatching { webJson.decodeFromString(StoredWeb.serializer(), record.json).imageUrl } - .getOrNull()?.let { removeImage(it) } - } - private fun delete(key: String) { writtenAt.remove(key) scope.launch { @@ -169,19 +137,6 @@ internal class PersistedLinkCardMemory( ?.let { _users[identity] = it } ?: error("stored profile names no account") } - record.key.startsWith(WEB_PREFIX) -> { - val stored = webJson.decodeFromString(StoredWeb.serializer(), record.json) - val state = stored.toState() - // Freshness comes from the row itself. A stale row is dropped like one that no - // longer decodes, and the link is looked up again the ordinary way. - val age = (now() - record.updatedAt).milliseconds - val ttl = if (state is LinkCard.Web.State.Resolved) WebLinks.RESOLVED_TTL else WebLinks.EMPTY_TTL - if (age > ttl) { - imageOf(state)?.let { removeImage(it) } - error("stale web row") - } - storeWeb(record.key.removePrefix(WEB_PREFIX), state, at = record.updatedAt) - } else -> error("unknown key") } }.isSuccess @@ -217,33 +172,6 @@ internal class PersistedLinkCardMemory( } } - /** - * A web card's answer as stored, field names shared with iOS. A null title is - * [LinkCard.Web.State.None]. Every field is written, nulls included. - */ - @Serializable - private data class StoredWeb( - val title: String? = null, - val description: String? = null, - val imageUrl: String? = null, - val host: String? = null, - ) { - fun toState(): LinkCard.Web.State = - if (title == null) { - LinkCard.Web.State.None - } else { - LinkCard.Web.State.Resolved(title, description, imageUrl, host ?: error("web row without a host")) - } - - companion object { - fun of(state: LinkCard.Web.State) = when (state) { - is LinkCard.Web.State.Resolved -> StoredWeb(state.title, state.description, state.imageUrl, state.host) - // Loading is never stored; it stands for no answer, so it is written as none only if asked to. - LinkCard.Web.State.None, LinkCard.Web.State.Loading -> StoredWeb() - } - } - } - @Serializable private data class StoredRequirement( val amount: String?, @@ -257,12 +185,10 @@ internal class PersistedLinkCardMemory( val REWRITE_AFTER = 1.days const val GROUP_PREFIX = "group:" const val USER_PREFIX = "user:" - const val WEB_PREFIX = "web:" const val BY_ID = "id:" const val BY_NAME = "name:" val json = Json { ignoreUnknownKeys = true } - val webJson = Json { ignoreUnknownKeys = true; encodeDefaults = true } fun groupKey(chatId: ChatId) = GROUP_PREFIX + chatId.bytes.toHexString() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageClient.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageClient.kt index 3496b8328..686f370b1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageClient.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageClient.kt @@ -17,12 +17,10 @@ import java.io.IOException * The client for a preview's picture, built under the same rules as [webPreviewClient]: no cookies, * no cache, no system proxy, [PublicOnlyDns], and no redirect followed by OkHttp itself. * [WebImageInterceptor] follows them by hand and holds the image to the page rules (decision 4). - * - * [enabled] is the kill switch: off, the call fails without a request. */ -internal fun webImageClient(dns: Dns = PublicOnlyDns(), enabled: () -> Boolean = { true }): OkHttpClient = +internal fun webImageClient(dns: Dns = PublicOnlyDns()): OkHttpClient = webClientBuilder(dns) - .addInterceptor(WebImageInterceptor(enabled)) + .addInterceptor(WebImageInterceptor()) .build() /** @@ -33,10 +31,9 @@ internal fun webImageClient(dns: Dns = PublicOnlyDns(), enabled: () -> Boolean = * `Accept-Encoding`) is not sent. A failure is an [IOException], which the loader treats as "no * image"; the card then draws without one. */ -internal class WebImageInterceptor(private val enabled: () -> Boolean) : Interceptor { +internal class WebImageInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - if (!enabled()) throw IOException("web previews off") var current = chain.request().url // The first request plus MAX_REDIRECTS redirects, as for pages. repeat(WebLinks.MAX_REDIRECTS + 1) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt deleted file mode 100644 index 2518d4fdb..000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoader.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.flipcash.app.messenger.internal.link - -import android.content.Context -import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi -import coil3.disk.DiskCache -import coil3.network.CacheStrategy -import coil3.network.NetworkRequest -import coil3.network.NetworkResponse -import coil3.request.Options -import coil3.network.okhttp.OkHttpNetworkFetcherFactory -import okhttp3.OkHttpClient -import okio.Path.Companion.toOkioPath -import java.io.File - -/** The most the preview pictures may take on disk; Coil drops the least recently used past it. */ -internal const val WEB_PREVIEW_DISK_BYTES = 20L * 1024 * 1024 - -/** - * The preview pictures' own store, next to and apart from the app loader's `image_cache`, so a - * picture of an outside page never sits in a store the rest of the app reads. - */ -internal fun webPreviewDiskCache(context: Context): DiskCache = - DiskCache.Builder() - .directory(File(context.cacheDir, "web_preview_images").toOkioPath()) - .maxSizeBytes(WEB_PREVIEW_DISK_BYTES) - .build() - -/** - * The loader for a preview's picture, on [client] from [webImageClient] and nothing else: it shares - * neither the app's loader nor that loader's client, which keeps cookies and follows any redirect. - * - * Pictures are kept in [diskCache], a store of the preview's own, under [WebImageCacheStrategy]. - */ -internal fun webPreviewImageLoader( - context: Context, - client: OkHttpClient, - diskCache: DiskCache = webPreviewDiskCache(context), -): ImageLoader = - ImageLoader.Builder(context) - .components { add(OkHttpNetworkFetcherFactory(callFactory = { client }, cacheStrategy = { WebImageCacheStrategy })) } - .diskCache(diskCache) - .build() - -/** - * What goes to and comes from the preview store. A stored picture is always read, with no check of - * its age or headers: its row decides how long it is wanted, and the picture goes when the row does. - * Only a 2xx answer is written. Coil's default also keeps a 301, 404, 410 and a few more, for a - * response cache to replay; a preview has no use for a stored failure. - */ -@OptIn(ExperimentalCoilApi::class) -internal object WebImageCacheStrategy : CacheStrategy { - override suspend fun read( - cacheResponse: NetworkResponse, - networkRequest: NetworkRequest, - options: Options, - ) = CacheStrategy.ReadResult(cacheResponse) - - override suspend fun write( - cacheResponse: NetworkResponse?, - networkRequest: NetworkRequest, - networkResponse: NetworkResponse, - options: Options, - ) = if (networkResponse.code in 200 until 300) { - CacheStrategy.WriteResult(networkResponse) - } else { - CacheStrategy.WriteResult.DISABLED - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageStore.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageStore.kt deleted file mode 100644 index 28fe24f33..000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebImageStore.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.flipcash.app.messenger.internal.link - -/** - * The preview pictures kept on disk, as the row store sees them: it only ever needs to drop one. - * An interface so a test of the rows needs no Coil disk. - */ -internal interface WebImageStore { - /** Deletes the picture stored for [url], the resolved `imageUrl` of a row. A missing one is fine. */ - fun remove(url: String) -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt index 82d44f5f2..63b03d7a4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookup.kt @@ -114,10 +114,8 @@ internal fun Response.isEncoded() = headers.values("Content-Encoding") /** The client implementation of the spec's LinkMetadataSource. A server RPC replaces it. */ internal class WebLinkLookup( private val client: OkHttpClient, - private val enabled: suspend () -> Boolean, private val dispatchers: DispatcherProvider, private val deadline: Duration = WebLinks.LOOKUP_DEADLINE, - private val memory: LinkCardMemory? = null, ) { private val inFlight = Semaphore(WebLinks.MAX_CONCURRENT) @@ -135,7 +133,6 @@ internal class WebLinkLookup( } private suspend fun fetch(url: String): Result = withContext(dispatchers.IO) { - if (!enabled()) return@withContext Result.failure(IllegalStateException("web previews off")) runCatching { // One budget of redirects for the link and, if it is needed, the home page. val hops = Hops() @@ -149,21 +146,16 @@ internal class WebLinkLookup( /** * The home page of [finalUrl]'s host, for a link that is private or has no card of its own (parity - * decisions P23, P24). A remembered answer for it is used without a request. Its answer is - * remembered under its own key too, None included. Running out of redirects there is the one - * answer that is not remembered for the home page: the link gets None and the home key nothing. + * decisions P23, P24). */ private suspend fun homePage(finalUrl: HttpUrl, hops: Hops): LinkCard.Web.State { if (finalUrl.encodedPath == "/" && finalUrl.query == null) return LinkCard.Web.State.None val home = finalUrl.newBuilder().encodedPath("/").query(null).fragment(null).build() - val key = WebLinks.cacheKey(home.toString()) - key?.let { k -> memory?.webs?.get(k)?.let { return it } } val state = when (val page = fetchPage(home, hops)) { is Page.Answer -> page.state is Page.Retry -> LinkCard.Web.State.None Page.OutOfRedirects -> return LinkCard.Web.State.None } - if (key != null) memory?.putWeb(key, state) return state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPreviewModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPreviewModule.kt new file mode 100644 index 000000000..2e676767f --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/WebPreviewModule.kt @@ -0,0 +1,44 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.libs.coroutines.DispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import javax.inject.Qualifier +import javax.inject.Singleton + +/** The client for fetching outside pages: no cookies, public addresses only, bodies not logged. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class WebPreview + +/** The client for the picture a page names: the page rules plus a cap on the body, see [webImageClient]. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class WebPreviewImages + +/** The guarded clients and lookup the sender's link preview is built with. */ +@Module +@InstallIn(SingletonComponent::class) +internal object WebPreviewModule { + + // Not the app's singleton client: it logs bodies at Level.BODY and keeps cookies. + @Provides + @Singleton + @WebPreview + fun provideWebPreviewClient(): OkHttpClient = webPreviewClient() + + @Provides + @Singleton + @WebPreviewImages + fun provideWebImageClient(): OkHttpClient = webImageClient() + + @Provides + @Singleton + fun provideWebLinkLookup( + @WebPreview client: OkHttpClient, + dispatchers: DispatcherProvider, + ): WebLinkLookup = WebLinkLookup(client = client, dispatchers = dispatchers) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index cc699102b..952ea6bf3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -90,7 +90,6 @@ import com.flipcash.shared.common.ui.profile.PrefetchProfileCover @Composable internal fun MessengerScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val webLinkPreviewsEnabled by viewModel.webLinkPreviewsEnabled.collectAsStateWithLifecycle() val previewsReady by viewModel.previewsReady.collectAsStateWithLifecycle() val messages = viewModel.messages.collectAsLazyPagingItems() val mediaProgress = viewModel.mediaSendProgress.collectAsStateWithLifecycle() @@ -512,8 +511,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { otherReadPointer = otherReadPointer, onAction = chatActionHandler, linkCardResolution = viewModel.linkCardResolution, - webPreviewImageLoader = viewModel.webPreviewImageLoader, - webLinkPreviewsEnabled = webLinkPreviewsEnabled, onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, topBarBottom = barHeight, unreadBelowCount = unreadBelowCount, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index a229b1d58..4214278fd 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -77,11 +77,7 @@ import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.LinkCardResolution import com.flipcash.shared.chat.models.LocalChatActionHandler -import coil3.ImageLoader import com.flipcash.shared.chat.models.LocalLinkCardResolution -import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode -import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader -import com.flipcash.shared.chat.models.WebLinkPreviewMode import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner @@ -116,8 +112,6 @@ internal fun MessageList( otherReadPointer: MessagePointer? = null, onAction: ChatActionHandler, linkCardResolution: LinkCardResolution, - webPreviewImageLoader: ImageLoader? = null, - webLinkPreviewsEnabled: Boolean = true, onJumpConsumed: () -> Unit = {}, topBarBottom: Dp = 0.dp, unreadBelowCount: Int = 0, @@ -137,14 +131,6 @@ internal fun MessageList( CompositionLocalProvider( LocalChatActionHandler provides onAction, LocalLinkCardResolution provides linkCardResolution, - // A viewer outside the group has not agreed to this device contacting a site a stranger - // linked, so the card waits for a tap. - LocalWebLinkPreviewMode provides when { - !webLinkPreviewsEnabled -> WebLinkPreviewMode.Off - state.isOutsideGroup -> WebLinkPreviewMode.TapToLoad - else -> WebLinkPreviewMode.Automatic - }, - LocalWebPreviewImageLoader provides webPreviewImageLoader, ) { // Whether the list has settled where this visit opens it: at the unread divider, or at the // bottom when there is none. Saveable for the same reason refreshSettled is — the screen is diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt index 2763078fb..41947e8c4 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt @@ -75,7 +75,7 @@ class ComposerLinkPreviewTest { val scope = CoroutineScope(Dispatchers.Default) val preview = ComposerLinkPreview( classifier = classifier, - lookup = WebLinkLookup(pages, enabled = { true }, dispatchers = dispatchers), + lookup = WebLinkLookup(pages, dispatchers = dispatchers), imageClient = mock(), uploads = mock(), images = mock(), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index d6a52917f..2c9b045e5 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 @@ -637,104 +637,17 @@ class LinkCardResolverTest { assertEquals(1, calls) } - private val webCard = LinkCard.Web(url = "https://example.com/a#one", start = 0, end = 25) - private val webResolved = LinkCard.Web.State.Resolved( - title = "Example", - description = "A page", - imageUrl = "https://example.com/i.png", - host = "example.com", - ) - - private fun TestScope.webResolver( - memory: LinkCardMemory = LinkCardMemory(), - web: suspend (String) -> Result, - ) = LinkCardResolver( - scope = backgroundScope, - giftCard = { Result.failure(IllegalStateException("unused")) }, - tokenMetadata = { Result.failure(IllegalStateException("unused")) }, - group = { Result.failure(IllegalStateException("unused")) }, - user = { Result.failure(IllegalStateException("unused")) }, - web = web, - memory = memory, - ) - - @Test - fun `peek of an unknown web card is null`() = runTest { - val resolver = webResolver { Result.success(webResolved) } - assertNull(resolver.peek(webCard)) - } - - @Test - fun `a web link is looked up once, even when two cards differ only by fragment`() = runTest { - var calls = 0 - val resolver = webResolver { calls++; Result.success(webResolved) } - assertEquals(webResolved, (resolver.resolve(webCard) as LinkCard.Web).state) - assertEquals(webResolved, (resolver.resolve(webCard.copy(url = "https://example.com/a#two")) as LinkCard.Web).state) - assertEquals(webResolved, (resolver.peek(webCard.copy(url = "https://example.com/a")) as LinkCard.Web).state) - assertEquals(1, calls) - } - - @Test - fun `a failed web lookup ends the draw as none and is not memoized`() = runTest { - var calls = 0 - val memory = LinkCardMemory() - val resolver = webResolver(memory) { calls++; Result.failure(java.io.IOException("offline")) } - assertEquals(LinkCard.Web.State.None, (resolver.resolve(webCard) as LinkCard.Web).state) - assertNull(resolver.peek(webCard)) - assertTrue(memory.webs.isEmpty()) - val revision = resolver.revision.value - resolver.resolve(webCard) - assertEquals(2, calls) - assertEquals(revision, resolver.revision.value) - } - @Test - fun `none and resolved web answers are memoized and bump the revision`() = runTest { - for (answer in listOf(LinkCard.Web.State.None, webResolved)) { - val resolver = webResolver { Result.success(answer) } - val before = resolver.revision.value - resolver.resolve(webCard) - assertEquals(before + 1, resolver.revision.value) - assertEquals(answer, (resolver.peek(webCard) as LinkCard.Web).state) - } - } - - @Test - fun `a web link that cannot be keyed resolves to none without a lookup`() = runTest { - var calls = 0 - val resolver = webResolver { calls++; Result.success(webResolved) } - val bad = webCard.copy(url = "not a url") - assertEquals(LinkCard.Web.State.None, (resolver.resolve(bad) as LinkCard.Web).state) - assertEquals(0, calls) - } - - private suspend fun TestScope.expiry(answer: LinkCard.Web.State, ttl: kotlin.time.Duration) { - var now = 1_000_000L - var calls = 0 - val memory = LinkCardMemory(clock = { now }) - val resolver = webResolver(memory) { calls++; Result.success(answer) } - resolver.resolve(webCard) - assertEquals(1, calls) - - now += ttl.inWholeMilliseconds - assertEquals(answer, (resolver.peek(webCard) as LinkCard.Web).state) - resolver.resolve(webCard) - assertEquals(1, calls) - - now += 1 + fun `a web card resolves to itself and peeks as unknown`() = runTest { + val webCard = LinkCard.Web(url = "https://example.com/a#one", start = 0, end = 25) + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + ) assertNull(resolver.peek(webCard)) - assertTrue(memory.webs.isEmpty()) - assertEquals(answer, (resolver.resolve(webCard) as LinkCard.Web).state) - assertEquals(2, calls) - } - - @Test - fun `a none web answer expires in memory at the empty ttl and is looked up again`() = runTest { - expiry(LinkCard.Web.State.None, WebLinks.EMPTY_TTL) - } - - @Test - fun `a resolved web answer expires in memory at the resolved ttl and is looked up again`() = runTest { - expiry(webResolved, WebLinks.RESOLVED_TTL) + assertEquals(webCard, resolver.resolve(webCard)) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt index 6fc337017..e86a8fa78 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LookupVectorTest.kt @@ -3,10 +3,7 @@ package com.flipcash.app.messenger.internal.link import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.chat.models.LinkCard import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -31,7 +28,7 @@ import kotlin.test.fail /** * Runs every `lookups` vector of `link_metadata.json`: the scripted responses by URL, the requests - * made in order, the result, the card, and the keys the lookup and the resolver wrote. All failing + * made in order, the result, and the card. All failing * vectors are reported together. The fixture is a synced copy, so a disagreement is fixed in the * canonical file, never here. */ @@ -50,16 +47,6 @@ class LookupVectorTest { .bufferedReader().use { it.readText() } ) - /** What the memory was asked to hold since the vector began, by key. Seeding is not recorded. */ - private class RecordingMemory : LinkCardMemory() { - val puts = LinkedHashMap() - var recording = false - override fun putWeb(key: String, state: LinkCard.Web.State) { - if (recording) puts[key] = state - super.putWeb(key, state) - } - } - private fun JSONArray.objects() = (0 until length()).map { getJSONObject(it) } private fun state(o: JSONObject?): LinkCard.Web.State.Resolved? = o?.let { @@ -71,11 +58,6 @@ class LookupVectorTest { ) } - private fun stored(row: JSONObject): LinkCard.Web.State = when (row.getString("state")) { - "resolved" -> state(row.getJSONObject("card"))!! - else -> LinkCard.Web.State.None - } - private fun htmlOf(r: JSONObject): ByteArray { val html = r.optString("html", "") if (!r.has("fillBytes")) return html.toByteArray() @@ -118,29 +100,9 @@ class LookupVectorTest { builder.body(Counting(type, htmlOf(r), pulled)).build() }).build() - val memory = RecordingMemory() - v.getJSONArray("cachedBefore").objects().forEach { memory.putWeb(it.getString("key"), stored(it)) } - memory.recording = true - - val lookup = WebLinkLookup(client, { true }, realIo, memory = memory) - var lookupResult: Result? = null - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val lookup = WebLinkLookup(client, realIo) val url = v.getString("url") - val resolved = try { - runBlocking { - LinkCardResolver( - scope = scope, - giftCard = { Result.failure(IllegalStateException("unused")) }, - tokenMetadata = { Result.failure(IllegalStateException("unused")) }, - group = { Result.failure(IllegalStateException("unused")) }, - user = { Result.failure(IllegalStateException("unused")) }, - web = { lookup(it).also { r -> lookupResult = r } }, - memory = memory, - ).resolve(LinkCard.Web(url, 0, url.length)) as LinkCard.Web - } - } finally { - scope.cancel() - } + val lookupResult: Result = runBlocking { lookup(url) } val expectedFetches = v.getJSONArray("fetches").let { a -> (0 until a.length()).map { a.getString(it) } } if (seen != expectedFetches) problems += "fetches $seen, expected $expectedFetches" @@ -152,13 +114,8 @@ class LookupVectorTest { "resolved" -> { val card = state(v.getJSONObject("card")) if (result?.getOrNull() != card) problems += "expected $card, got $result" - if (resolved.state != card) problems += "resolver drew ${resolved.state}, expected $card" } } - if (resolved.url != url) problems += "the card opens ${resolved.url}, expected the original $url" - - val expectedCached = v.getJSONArray("cached").objects().associate { it.getString("key") to stored(it) } - if (memory.puts != expectedCached) problems += "cached ${memory.puts}, expected $expectedCached" if (name == "stops-after-head" && pulled.get() > 64 * 1024) { problems += "pulled ${pulled.get()} bytes, the body after the head must not be downloaded" @@ -170,7 +127,8 @@ class LookupVectorTest { fun `every lookup vector in the fixture holds`() { val vectors = fixture().getJSONArray("lookups").objects() check(vectors.size == 20) { "expected 20 lookup vectors, found ${vectors.size}" } - val failures = vectors.flatMap { v -> + // Vectors that start from a remembered home page test the receive-side cache, which is gone. + val failures = vectors.filter { it.getJSONArray("cachedBefore").length() == 0 }.flatMap { v -> runCatching { runVector(v) }.getOrElse { listOf("${v.getString("name")}: threw $it") } } if (failures.isNotEmpty()) { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt deleted file mode 100644 index 59fbd814a..000000000 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.flipcash.app.messenger.internal.link - -import com.flipcash.services.models.chat.LinkPreview -import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.app.router.Router -import com.flipcash.libs.coroutines.TestDispatcherProvider -import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatMessage -import com.flipcash.services.models.chat.MessageContent -import com.flipcash.shared.chat.models.LinkCard -import com.getcode.opencode.model.core.bytes -import dev.theolm.rinku.DeepLink -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.IOException -import java.util.UUID -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant - -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE) -class MessageLinkPrefetcherTest { - - private val groupUuid = UUID.fromString("0d8e3c7a-5b2f-4a91-8e6d-3c1b0a9f8e7d") - private val chatId = ChatId(groupUuid.bytes) - private val inviteText = "join us https://app.flipcash.com/chat/$groupUuid" - - private val resolved = LinkCard.GroupInvite.State.Resolved( - title = "All Jeffy Holders", - picture = null, - memberCount = 42, - requirement = null, - ) - - /** Classifies group invites and nothing else; the classifier's own rules are tested elsewhere. */ - private val router = object : Router { - override fun classify(deepLink: DeepLink): DeeplinkType? { - val segments = deepLink.data.substringAfter("://").split('/').drop(1) - return if (segments.firstOrNull() == "chat") { - DeeplinkType.GroupChatInvite(ChatId(UUID.fromString(segments[1]).bytes)) - } else { - null - } - } - - override fun dispatch(deepLink: DeepLink) = error("not used") - } - - private fun message(text: String, id: Long = 1, redacted: Boolean = false) = ChatMessage( - messageId = id, - senderId = null, - content = listOf(MessageContent.Text(text)), - timestamp = Instant.fromEpochSeconds(id), - unreadSeq = id, - redacted = redacted, - ) - - private fun TestScope.prefetcher( - memory: LinkCardMemory, - web: suspend (String) -> Result = { Result.failure(IOException("not used")) }, - webEnabled: suspend () -> Boolean = { true }, - group: suspend (ChatId) -> Result = { Result.failure(IOException("not used")) }, - ) = MessageLinkPrefetcher( - classifier = LinkCardClassifier(router), - memory = memory, - group = group, - user = { Result.failure(IOException("not used")) }, - web = web, - dispatchers = TestDispatcherProvider(StandardTestDispatcher(testScheduler)), - webEnabled = webEnabled, - ) - - @Test - fun `an invite in an arriving message is resolved before the message is written`() = runTest { - val memory = LinkCardMemory() - prefetcher(memory) { Result.success(resolved) } - .prefetch(listOf(message(inviteText)), wait = 1.seconds) - - assertEquals(resolved, memory.groups[chatId]) - } - - @Test - fun `a lookup slower than the wait still lands, after the write has gone ahead`() = runTest { - val memory = LinkCardMemory() - val answer = CompletableDeferred>() - prefetcher(memory) { answer.await() } - .prefetch(listOf(message(inviteText)), wait = 1.seconds) - - assertNull(memory.groups[chatId]) - - answer.complete(Result.success(resolved)) - advanceUntilIdle() - assertEquals(resolved, memory.groups[chatId]) - } - - @Test - fun `a link already answered is not asked about again, nor one already being asked about`() = runTest { - val memory = LinkCardMemory() - var asked = 0 - val answer = CompletableDeferred>() - val prefetcher = prefetcher(memory) { asked++; answer.await() } - - prefetcher.prefetch(listOf(message(inviteText, id = 1))) - prefetcher.prefetch(listOf(message(inviteText, id = 2))) - advanceUntilIdle() - answer.complete(Result.success(resolved)) - advanceUntilIdle() - prefetcher.prefetch(listOf(message(inviteText, id = 3))) - advanceUntilIdle() - - assertEquals(1, asked) - } - - @Test - fun `a failed lookup leaves nothing behind`() = runTest { - val memory = LinkCardMemory() - prefetcher(memory) { Result.failure(IOException("offline")) } - .prefetch(listOf(message(inviteText)), wait = 1.seconds) - - assertTrue(memory.groups.isEmpty()) - } - - @Test - fun `a redacted message is not read for links`() = runTest { - val memory = LinkCardMemory() - var asked = 0 - prefetcher(memory) { asked++; Result.success(resolved) } - .prefetch(listOf(message(inviteText, redacted = true)), wait = 1.seconds) - - assertEquals(0, asked) - } - - private val pageUrl = "https://example.com/article" - private val pageText = "read $pageUrl" - private val page = LinkCard.Web.State.Resolved( - title = "An article", - description = null, - imageUrl = null, - host = "example.com", - ) - - @Test - fun `a web link is not fetched unless the caller allows it`() = runTest { - val memory = LinkCardMemory() - var asked = 0 - prefetcher(memory, web = { asked++; Result.success(page) }) - .prefetch(listOf(message(pageText)), wait = 1.seconds) - - assertEquals(0, asked) - assertTrue(memory.webs.isEmpty()) - } - - @Test - fun `a web link whose sender built its preview is not fetched`() = runTest { - val memory = LinkCardMemory() - var asked = 0 - val sent = message(pageText).copy( - content = listOf(MessageContent.Text(pageText, linkPreview = LinkPreview(pageUrl, "An article", "", null))), - ) - prefetcher(memory, web = { asked++; Result.success(page) }) - .prefetch(listOf(sent), wait = 1.seconds, webLinks = true) - - assertEquals(0, asked) - assertTrue(memory.webs.isEmpty()) - } - - @Test - fun `a web link is fetched once when allowed, and not again once held`() = runTest { - val memory = LinkCardMemory() - var asked = 0 - val prefetcher = prefetcher(memory, web = { asked++; Result.success(page) }) - prefetcher.prefetch(listOf(message(pageText)), wait = 1.seconds, webLinks = true) - prefetcher.prefetch(listOf(message(pageText, id = 2)), wait = 1.seconds, webLinks = true) - - assertEquals(1, asked) - assertEquals(page, memory.webs[WebLinks.cacheKey(pageUrl)]) - } - - @Test - fun `a web link is not fetched while web link previews are off, and is again once on`() = runTest { - val memory = LinkCardMemory() - var asked = 0 - var on = false - val prefetcher = prefetcher(memory, web = { asked++; Result.success(page) }, webEnabled = { on }) - - prefetcher.prefetch(listOf(message(pageText)), wait = 1.seconds, webLinks = true) - assertEquals(0, asked) - assertTrue(memory.webs.isEmpty()) - - on = true - prefetcher.prefetch(listOf(message(pageText, id = 2)), wait = 1.seconds, webLinks = true) - assertEquals(1, asked) - } - - @Test - fun `a group invite is prefetched with web link previews off`() = runTest { - val memory = LinkCardMemory() - prefetcher(memory, group = { Result.success(resolved) }, webEnabled = { false }) - .prefetch(listOf(message(inviteText)), wait = 1.seconds, webLinks = true) - - assertEquals(resolved, memory.groups[chatId]) - } - - @Test - fun `a group invite is prefetched whether or not web links are allowed`() = runTest { - val memory = LinkCardMemory() - prefetcher(memory, group = { Result.success(resolved) }) - .prefetch(listOf(message(inviteText)), wait = 1.seconds, webLinks = false) - - assertEquals(resolved, memory.groups[chatId]) - } -} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt index f7a0bc4fa..f5cbd2a29 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/PersistedLinkCardMemoryTest.kt @@ -89,20 +89,10 @@ class PersistedLinkCardMemoryTest { private var clock = 100.days.inWholeMilliseconds - private class RecordingImages : WebImageStore { - val removed = mutableListOf() - override fun remove(url: String) { - removed += url - } - } - - private val images = RecordingImages() - private fun TestScope.memory(table: FakeTable, viewer: List? = myId) = PersistedLinkCardMemory( store = table.source, userManager = mock { on { accountId } doReturn viewer }, resources = mock(), - images = images, dispatchers = TestDispatcherProvider(StandardTestDispatcher(testScheduler)), now = { clock }, ) @@ -240,249 +230,4 @@ class PersistedLinkCardMemoryTest { assertNull(memory.groups[chatId]) } - - private val webResolved = LinkCard.Web.State.Resolved( - title = "Example", - description = null, - imageUrl = "https://example.com/i.png", - host = "example.com", - ) - private val webKey = "https://example.com/a" - - @Test - fun `a web answer round-trips under a web key with the agreed fields`() = runTest { - val table = FakeTable() - memory(table).apply { - putWeb(webKey, webResolved) - putWeb("https://example.com/none", LinkCard.Web.State.None) - } - advanceUntilIdle() - assertEquals( - """{"title":"Example","description":null,"imageUrl":"https://example.com/i.png","host":"example.com"}""", - table.rows["web:$webKey"]!!.json, - ) - assertEquals( - """{"title":null,"description":null,"imageUrl":null,"host":null}""", - table.rows["web:https://example.com/none"]!!.json, - ) - - table.reopen() - val restarted = memory(table) - advanceUntilIdle() - restarted.awaitLoaded() - assertEquals(webResolved, restarted.webs[webKey]) - assertEquals(LinkCard.Web.State.None, restarted.webs["https://example.com/none"]) - } - - private suspend fun TestScope.webReloaded(state: LinkCard.Web.State, ageMs: Long): LinkCardMemory { - val table = FakeTable() - memory(table).putWeb(webKey, state) - advanceUntilIdle() - clock += ageMs - table.reopen() - val restarted = memory(table) - advanceUntilIdle() - restarted.awaitLoaded() - return restarted - } - - @Test - fun `a resolved web row reads as absent only once it is older than the resolved TTL`() = runTest { - val ttl = 168.hours.inWholeMilliseconds - assertEquals(webResolved, webReloaded(webResolved, ttl).webs[webKey]) - assertNull(webReloaded(webResolved, ttl + 1).webs[webKey]) - } - - @Test - fun `a none web row reads as absent only once it is older than the empty TTL`() = runTest { - val ttl = 24.hours.inWholeMilliseconds - assertEquals(LinkCard.Web.State.None, webReloaded(LinkCard.Web.State.None, ttl).webs[webKey]) - assertNull(webReloaded(LinkCard.Web.State.None, ttl + 1).webs[webKey]) - } - - @Test - fun `a none web row is not kept for the resolved TTL`() = runTest { - assertNull(webReloaded(LinkCard.Web.State.None, 25.hours.inWholeMilliseconds).webs[webKey]) - assertEquals(webResolved, webReloaded(webResolved, 25.hours.inWholeMilliseconds).webs[webKey]) - } - - @Test - fun `an unchanged web answer is not written again within a day`() = runTest { - val table = FakeTable() - val memory = memory(table) - advanceUntilIdle() - memory.awaitLoaded() - memory.putWeb(webKey, webResolved) - advanceUntilIdle() - memory.putWeb(webKey, webResolved) - advanceUntilIdle() - assertEquals(1, table.writes.count { it == "web:$webKey" }) - } - - @Test - fun `a failed web lookup through the resolver leaves nothing on disk`() = runTest { - val table = FakeTable() - val resolver = LinkCardResolver( - scope = backgroundScope, - giftCard = { Result.failure(IllegalStateException("unused")) }, - tokenMetadata = { Result.failure(IllegalStateException("unused")) }, - group = { Result.failure(IllegalStateException("unused")) }, - user = { Result.failure(IllegalStateException("unused")) }, - web = { Result.failure(java.io.IOException("offline")) }, - memory = memory(table), - ) - resolver.resolve(LinkCard.Web(url = webKey, start = 0, end = 21)) - advanceUntilIdle() - assertTrue(table.writes.isEmpty()) - assertTrue(table.rows.isEmpty()) - } - - @Test - fun `an entry loaded from a 23 hour old none row expires an hour and a millisecond later`() = runTest { - val loaded = webReloaded(LinkCard.Web.State.None, 23.hours.inWholeMilliseconds) - assertEquals(LinkCard.Web.State.None, loaded.webs[webKey]) - clock += 1.hours.inWholeMilliseconds - assertEquals(LinkCard.Web.State.None, loaded.webs[webKey]) - clock += 1 - assertNull(loaded.webs[webKey]) - } - - private fun webRow(key: String, state: LinkCard.Web.State.Resolved, updatedAt: Long) = LinkPreviewRecord( - key = "web:$key", - json = """{"title":"${state.title}","description":null,"imageUrl":${state.imageUrl?.let { "\"$it\"" }},"host":"${state.host}"}""", - updatedAt = updatedAt, - ) - - @Test - fun `a stale resolved row takes its picture with it when the table is read`() = runTest { - val table = FakeTable() - table.rows["web:$webKey"] = webRow(webKey, webResolved, updatedAt = clock - 169.hours.inWholeMilliseconds) - table.reopen() - - memory(table) - advanceUntilIdle() - - assertTrue(table.rows.isEmpty()) - assertEquals(listOf(webResolved.imageUrl), images.removed) - } - - @Test - fun `a fresh resolved row keeps its picture`() = runTest { - val table = FakeTable() - table.rows["web:$webKey"] = webRow(webKey, webResolved, updatedAt = clock - 167.hours.inWholeMilliseconds) - table.reopen() - - memory(table) - advanceUntilIdle() - - assertTrue(images.removed.isEmpty()) - } - - @Test - fun `a row the table drops for age takes its picture with it`() = runTest { - val table = FakeTable() - table.droppedByAge = listOf(webRow(webKey, webResolved, updatedAt = clock - 31.days.inWholeMilliseconds)) - table.reopen() - - memory(table) - advanceUntilIdle() - - assertEquals(listOf(webResolved.imageUrl), images.removed) - } - - @Test - fun `a group row dropped for age removes no picture`() = runTest { - val table = FakeTable() - table.droppedByAge = listOf(LinkPreviewRecord("group:ab", "{}", updatedAt = 0)) - table.reopen() - - memory(table) - advanceUntilIdle() - - assertTrue(images.removed.isEmpty()) - } - - @Test - fun `an answer replaced by none removes the picture of the one it replaced`() = runTest { - val table = FakeTable() - val memory = memory(table) - advanceUntilIdle() - memory.putWeb(webKey, webResolved) - advanceUntilIdle() - - memory.putWeb(webKey, LinkCard.Web.State.None) - advanceUntilIdle() - - assertEquals(listOf(webResolved.imageUrl), images.removed) - } - - @Test - fun `an answer replaced with a different picture removes the old one only`() = runTest { - val table = FakeTable() - val memory = memory(table) - advanceUntilIdle() - memory.putWeb(webKey, webResolved) - advanceUntilIdle() - - memory.putWeb(webKey, webResolved.copy(imageUrl = "https://example.com/j.png")) - advanceUntilIdle() - - assertEquals(listOf(webResolved.imageUrl), images.removed) - } - - @Test - fun `an answer put again with the same picture removes nothing`() = runTest { - val table = FakeTable() - val memory = memory(table) - advanceUntilIdle() - memory.putWeb(webKey, webResolved) - memory.putWeb(webKey, webResolved.copy(title = "Retitled")) - advanceUntilIdle() - - assertTrue(images.removed.isEmpty()) - } - - @Test - fun `an answer with no picture replaced by one removes nothing`() = runTest { - val table = FakeTable() - val memory = memory(table) - advanceUntilIdle() - memory.putWeb(webKey, webResolved.copy(imageUrl = null)) - memory.putWeb(webKey, webResolved) - advanceUntilIdle() - - assertTrue(images.removed.isEmpty()) - } - - @Test - fun `a database closing removes the pictures of the answers it held`() = runTest { - val table = FakeTable() - val memory = memory(table) - advanceUntilIdle() - memory.putWeb(webKey, webResolved) - advanceUntilIdle() - table.reopen() - advanceUntilIdle() - assertTrue(images.removed.isEmpty()) - - // Logged out: no database, so no rows, so no pictures to read. - table.opened.value = emptyList() - advanceUntilIdle() - - assertEquals(listOf(webResolved.imageUrl), images.removed) - } - - @Test - fun `a reload that still has the row keeps its picture`() = runTest { - val table = FakeTable() - val memory = memory(table) - advanceUntilIdle() - memory.putWeb(webKey, webResolved) - advanceUntilIdle() - - table.reopen() - advanceUntilIdle() - - assertTrue(images.removed.isEmpty()) - } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageClientTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageClientTest.kt index cd1424277..d782fa4ae 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageClientTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageClientTest.kt @@ -62,8 +62,8 @@ class WebImageClientTest { val interceptor = Interceptor { chain -> chain.request().also { seen += it }.let(handler) } } - private fun client(script: Script, enabled: Boolean = true): OkHttpClient = - webImageClient(enabled = { enabled }).newBuilder().addInterceptor(script.interceptor).build() + private fun client(script: Script): OkHttpClient = + webImageClient().newBuilder().addInterceptor(script.interceptor).build() /** The bytes the caller would decode, or the failure it would see. */ private fun load( @@ -74,8 +74,8 @@ class WebImageClientTest { client.newCall(Request.Builder().url(url).apply(configure).build()).execute().use { it.body.bytes() } } - private fun load(script: Script, url: String = "https://example.com/a.png", enabled: Boolean = true) = - load(client(script, enabled), url) + private fun load(script: Script, url: String = "https://example.com/a.png") = + load(client(script), url) /** The client only requests port 443, so a test server is reached by moving the call after the checks. */ private fun portTo(port: Int) = Interceptor { chain -> @@ -275,13 +275,6 @@ class WebImageClientTest { assertTrue(load(Script { throw IOException("down") }).isFailure) } - @Test - fun `with the flag off nothing is requested`() { - val script = Script { reply(it) } - assertTrue(load(script, enabled = false).isFailure) - assertTrue(script.seen.isEmpty()) - } - @Test fun `the client pins its transport settings`() { val client = webImageClient() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoaderTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoaderTest.kt deleted file mode 100644 index deef75b6e..000000000 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebImageLoaderTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.flipcash.app.messenger.internal.link - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import coil3.ImageLoader -import coil3.disk.DiskCache -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import coil3.size.Size -import kotlinx.coroutines.runBlocking -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File -import java.util.concurrent.atomic.AtomicInteger -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -/** - * P22b and P22c: the preview loader keeps pictures in a store of its own, 20 MB, written only for - * a successful answer and read whatever the headers said. The client here is a plain scripted one, - * so these cases see Coil's own cache strategy and not the rules [WebImageInterceptor] adds. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class WebImageLoaderTest { - - private val context: Context = ApplicationProvider.getApplicationContext() - private val url = "https://img.example.com/p.png" - private val calls = AtomicInteger() - - private fun client(code: Int = 200, headers: Map = emptyMap()) = OkHttpClient.Builder() - .addInterceptor { chain -> - calls.incrementAndGet() - Response.Builder() - .request(chain.request()).protocol(Protocol.HTTP_1_1).code(code).message("m") - .apply { headers.forEach { (k, v) -> header(k, v) } } - .body(byteArrayOf(1, 2, 3, 4).toResponseBody("image/png".toMediaType())) - .build() - } - .build() - - private fun loader(client: OkHttpClient): ImageLoader = webPreviewImageLoader(context, client) - - private fun ImageLoader.load() { - runBlocking { execute(ImageRequest.Builder(context).data(url).size(Size.ORIGINAL) - .memoryCachePolicy(CachePolicy.DISABLED).build()) } - } - - private fun ImageLoader.stored(): DiskCache.Snapshot? = diskCache?.openSnapshot(url) - - @Test - fun `the loader's disk cache is its own, 20 MB, under the cache dir`() { - val cache = assertNotNull(loader(client()).diskCache) - - assertEquals(20L * 1024 * 1024, cache.maxSize) - assertEquals(File(context.cacheDir, "web_preview_images").absolutePath, cache.directory.toFile().absolutePath) - assertNotEquals(File(context.cacheDir, "image_cache").absolutePath, cache.directory.toFile().absolutePath) - } - - @Test - fun `a successful answer is written under the image url`() { - val loader = loader(client()) - loader.load() - - assertNotNull(loader.stored()) - } - - @Test - fun `a failed answer is never written`() { - for (code in listOf(404, 410, 301, 500)) { - val loader = loader(client(code = code)) - loader.load() - - assertNull(loader.stored(), "HTTP $code should leave nothing on disk") - } - } - - @Test - fun `no-store does not keep a picture off the disk`() { - val loader = loader(client(headers = mapOf("Cache-Control" to "no-store"))) - loader.load() - - assertNotNull(loader.stored()) - } - - @Test - fun `a stored picture is read again without a request whatever its headers said`() { - val loader = loader(client(headers = mapOf("Cache-Control" to "max-age=0, must-revalidate"))) - loader.load() - loader.load() - - assertEquals(1, calls.get()) - } - - @Test - fun `removing a url from the store deletes its picture`() { - val loader = loader(client()) - loader.load() - assertNotNull(loader.stored()) - - CoilWebImageStore(loader.diskCache).remove(url) - - assertNull(loader.stored()) - } -} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt index 2b4095d92..e9ae0043e 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/WebLinkLookupTest.kt @@ -75,14 +75,13 @@ class WebLinkLookupTest { val interceptor = Interceptor { chain -> chain.request().also { seen += it }.let(handler) } } - private fun lookup(script: Script, enabled: Boolean = true) = WebLinkLookup( + private fun lookup(script: Script) = WebLinkLookup( client = webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), - enabled = { enabled }, dispatchers = realIo, ) - private fun fetch(script: Script, url: String = "https://example.com/a", enabled: Boolean = true) = - runBlocking { lookup(script, enabled)(url) } + private fun fetch(script: Script, url: String = "https://example.com/a") = + runBlocking { lookup(script)(url) } /** The lookup only requests port 443, so a test server is reached by moving the call after the checks. */ private fun portTo(port: Int) = Interceptor { chain -> @@ -210,19 +209,19 @@ class WebLinkLookupTest { Thread.sleep(150) if (req.url.encodedPath.length < 4) redirect(req, "https://example.com${req.url.encodedPath}x") else reply(req) } - val slow = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), { true }, realIo, deadline = 300.milliseconds) + val slow = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), realIo, deadline = 300.milliseconds) val result = runBlocking { slow("https://example.com/") } assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() !is CancellationException) // The same hops inside a longer deadline resolve. - val fast = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), { true }, realIo, deadline = 5.seconds) + val fast = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), realIo, deadline = 5.seconds) assertTrue(runBlocking { fast("https://example.com/") }.getOrThrow() is LinkCard.Web.State.Resolved) } @Test fun `time spent waiting for a permit does not count against the deadline`() { val script = Script { req -> Thread.sleep(250); reply(req) } - val lookup = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), { true }, realIo, deadline = 400.milliseconds) + val lookup = WebLinkLookup(webPreviewClient().newBuilder().addInterceptor(script.interceptor).build(), realIo, deadline = 400.milliseconds) val results = runBlocking(Dispatchers.IO) { (1..WebLinks.MAX_CONCURRENT + 1).map { async { lookup("https://example.com/$it") } }.awaitAll() } @@ -259,13 +258,6 @@ class WebLinkLookupTest { assertEquals("identity", request.header("Accept-Encoding")) } - @Test - fun `with the flag off the result is a failure and nothing is requested`() { - val script = Script { reply(it) } - assertTrue(fetch(script, enabled = false).isFailure) - assertTrue(script.seen.isEmpty()) - } - @Test fun `six calls at once never have more than four requests open`() { val open = AtomicInteger() @@ -321,7 +313,7 @@ class WebLinkLookupTest { } .build() val result = runBlocking { - WebLinkLookup(client, { true }, realIo)("https://$host/") + WebLinkLookup(client, realIo)("https://$host/") } assertEquals("gzip", sawEncoding, "OkHttp must not strip Content-Encoding") assertEquals("identity", server.takeRequest().headers["Accept-Encoding"]) @@ -363,7 +355,7 @@ class WebLinkLookupTest { } val client = webPreviewClient(Dns { host -> resolved += host; throw java.net.UnknownHostException(host) }) .newBuilder().proxySelector(selector).build() - val result = runBlocking { WebLinkLookup(client, { true }, realIo)("https://example.com/") } + val result = runBlocking { WebLinkLookup(client, realIo)("https://example.com/") } assertTrue(result.isFailure) assertEquals(0, proxy.requestCount) assertTrue(asked.isEmpty(), "proxy selector was consulted: $asked") @@ -391,7 +383,7 @@ class WebLinkLookupTest { .sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager) .addInterceptor(portTo(server.port)) .build() - val result = runBlocking { WebLinkLookup(client, { true }, realIo)("https://$host/") } + val result = runBlocking { WebLinkLookup(client, realIo)("https://$host/") } assertEquals(LinkCard.Web.State.None, result.getOrThrow()) assertEquals(1, server.requestCount) } finally { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt deleted file mode 100644 index 2398828a6..000000000 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.flipcash.shared.chat.models - -import androidx.compose.runtime.staticCompositionLocalOf -import coil3.ImageLoader - -/** - * Whether a web card asks for its page on its own, waits to be told, or is not drawn at all - * because web link previews are switched off. - */ -enum class WebLinkPreviewMode { Automatic, TapToLoad, Off } - -/** - * TapToLoad for a viewer outside the group, who has not agreed to this device contacting a site a - * stranger linked; Automatic otherwise. Never stored, so nothing kept says who looked. - */ -val LocalWebLinkPreviewMode = staticCompositionLocalOf { WebLinkPreviewMode.Automatic } - -/** - * The loader for a preview's picture: no cookies, public-only DNS, the page rules on every hop. - * Null draws no picture, because the app's own loader keeps cookies and follows any redirect. - */ -val LocalWebPreviewImageLoader = staticCompositionLocalOf { null } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 76749cb92..d9aa8e626 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 @@ -207,18 +207,15 @@ internal fun LinkCardView( * answer. */ @Composable -internal fun rememberResolvedCard(card: LinkCard, fetch: Boolean = true): LinkCard { +internal fun rememberResolvedCard(card: LinkCard): LinkCard { val resolution = LocalLinkCardResolution.current val revision by resolution.revision.collectAsState() var live by remember(card) { mutableStateOf(resolution.peek(card) ?: card) } // Keyed on the card, so a row recomposed onto a different message drops the previous link's // query instead of finishing it into the new card. - // - // [fetch] false still peeks, since a held answer costs no request, but asks nothing: a web card - // outside the group waits for a tap before this device contacts the site. - LaunchedEffect(card, revision, fetch) { - if (fetch) live = resolution.resolve(card) + LaunchedEffect(card, revision) { + live = resolution.resolve(card) } return live diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 8d4e3013b..a2225c74d 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 @@ -387,7 +387,7 @@ fun ChatListItem.ContentBubble.rendersBare(): Boolean = /** * [rendersBare], plus the case only composition can answer: a message that is nothing but a web - * link whose card has resolved, or is loading behind a placeholder, draws as the card alone (see [TextBubble]), so its "Edited" marker + * link whose card has resolved, draws as the card alone (see [TextBubble]), so its "Edited" marker * comes out below it like a Flipcash card row's. Asks the same [rememberWebLinkCard] the bubble * does, so the two agree on every frame. * @@ -399,7 +399,7 @@ fun ChatListItem.ContentBubble.rememberRendersBare(): Boolean { if (rendersBare()) return true val web = linkOnlyWebCard() ?: return false val state = rememberWebLinkCard(web) - return state.resolved != null || state.loading + return state.resolved != null } /** @@ -507,14 +507,14 @@ private fun TextBubble( val surround = if (quote != null) BubbleDefaults.surroundInset else BubbleDefaults.paddingHorizontal val bodyInset = BubbleDefaults.paddingHorizontal - surround // Asked here rather than in the card so the bubble can widen for a card that draws: the image - // would otherwise be squeezed to the text's width. A chip, a card still loading and an empty - // answer leave the bubble text-sized. + // would otherwise be squeezed to the text's width. A link with no preview leaves the bubble + // text-sized. val web = webCard?.let { rememberWebLinkCard(it) } val cardDrawn = web?.resolved != null // A link-only message holds the card's place while its lookup runs, so the link never shows // and the card then fills the same slot. An empty or failed answer ends the wait, and the text // bubble with the link takes over. - if (web != null && webCardAlone && (cardDrawn || web.loading)) { + if (web != null && webCardAlone && cardDrawn) { BareWebCard( web = web, isFromSelf = isFromSelf, @@ -708,9 +708,7 @@ private fun TextBubble( * Laid out as [BareLinkCard] is -- through [Bubble] with `bare`, so the jump flash and the width * ceiling stay where every bubble gets them, and a reply's citation above the card. * - * Only reached while the card is resolved or its lookup is running, when [WebLinkCard] draws a - * placeholder in its place. Should the answer come back empty, [TextBubble] draws the text bubble - * with the link again. + * Only reached while the card is resolved; a link with no preview draws as the text bubble. */ @Composable private fun BareWebCard( diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt index bf79bfefc..6a0ec23c7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WebLinkCard.kt @@ -1,14 +1,10 @@ package com.flipcash.shared.chat.ui import com.flipcash.shared.chat.media.ChatPhoto -import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -19,35 +15,23 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import com.flipcash.app.core.ui.shimmer -import com.flipcash.core.R import com.flipcash.shared.chat.models.LinkCard -import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode -import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader -import com.flipcash.shared.chat.models.WebLinkPreviewMode import com.getcode.ui.core.addIf import com.getcode.theme.CodeTheme internal const val WEB_LINK_CARD_TAG = "web_link_card" -internal const val WEB_LINK_CHIP_TAG = "web_link_chip" internal const val WEB_LINK_IMAGE_TAG = "web_link_image" -internal const val WEB_LINK_PLACEHOLDER_TAG = "web_link_placeholder" /** Width over height of the picture, the shape Open Graph images are sized for. */ private const val IMAGE_ASPECT = 1.91f @@ -56,74 +40,23 @@ private val PANEL_FILL = Color.White.copy(alpha = 0.08f) /** A bare card's outline: the group invite card's stroke, so both bare cards draw the same edge. */ private val BARE_STROKE = GroupInviteCardDefaults.STROKE -private val CHIP_FILL = Color.White.copy(alpha = 0.12f) private const val TITLE_LINES = 2 -private val TITLE_BAR_HEIGHT = 14.dp -private const val TITLE_BAR_SHORT_FRACTION = 0.6f private const val DESCRIPTION_LINES = 2 /** - * What a web card draws now: [resolved] once the page has answered, [chipHost] while the viewer is - * outside the group and has not asked, and neither otherwise. A card still loading, an empty - * answer, and a lookup that failed all draw nothing under a message's text. - * - * [loading] is true while a lookup is actually under way or about to be: automatic mode, or the chip - * tapped, with no answer yet. A link-only message holds its place with a placeholder for as long as - * it is, and [host] names the link's own host for it. + * What a web card draws: [resolved] when the message carries the preview its sender built, and + * nothing otherwise. The reader contacts no site, so a link without a preview draws as text only. */ @Stable internal class WebLinkCardState( val url: String, val resolved: LinkCard.Web.State.Resolved?, - val chipHost: String?, - val loading: Boolean, - val host: String, - val ask: () -> Unit, ) -/** - * The state of [card] for the viewer: asks for its page on its own, or only after the chip is - * tapped when the viewer is outside the group (see [LocalWebLinkPreviewMode]). - * - * The tap is remembered across scrolling, so a message drawn again after a failed fetch asks again - * with no chip. - */ +/** The state of [card]: its sender-built preview if it came with one. */ @Composable -internal fun rememberWebLinkCard(card: LinkCard.Web): WebLinkCardState { - val mode = LocalWebLinkPreviewMode.current - var asked by rememberSaveable(card.url) { mutableStateOf(false) } - // A preview the sender built comes resolved, and drawing it contacts no site, so it needs no tap. - (card.state as? LinkCard.Web.State.Resolved)?.let { built -> - return WebLinkCardState( - url = card.url, - resolved = built.takeIf { mode != WebLinkPreviewMode.Off }, - chipHost = null, - loading = false, - host = chipHostOf(card.url), - ask = {}, - ) - } - val fetching = mode == WebLinkPreviewMode.Automatic || (mode == WebLinkPreviewMode.TapToLoad && asked) - val live = rememberResolvedCard( - card = card, - fetch = fetching, - ) as? LinkCard.Web ?: card - val showChip = mode == WebLinkPreviewMode.TapToLoad && !asked && - live.state == LinkCard.Web.State.Loading - return WebLinkCardState( - url = card.url, - // Off draws no card at all, even from an answer held from before the flag was switched off. - resolved = (live.state as? LinkCard.Web.State.Resolved)?.takeIf { mode != WebLinkPreviewMode.Off }, - chipHost = if (showChip) chipHostOf(card.url) else null, - loading = fetching && live.state == LinkCard.Web.State.Loading, - host = chipHostOf(card.url), - ask = { asked = true }, - ) -} - -/** The host a chip names: lower case, without a leading "www.". */ -internal fun chipHostOf(url: String): String = - Uri.parse(url).host.orEmpty().lowercase().removePrefix("www.") +internal fun rememberWebLinkCard(card: LinkCard.Web): WebLinkCardState = + WebLinkCardState(url = card.url, resolved = card.state as? LinkCard.Web.State.Resolved) /** * The card under a message's text, or the chip that asks for it. The whole card is one target that @@ -143,32 +76,16 @@ internal fun WebLinkCard( interactive: Boolean = true, bareShape: Shape? = null, ) { - val resolved = state.resolved - val chipHost = state.chipHost - when { - resolved != null -> WebPreview( - preview = resolved, - url = state.url, - modifier = modifier, - interactive = interactive, - onLongClick = onLongClick, - onDoubleClick = onDoubleClick, - bareShape = bareShape, - ) - - // Only a bare card holds its place: under text, a card that has not landed draws nothing. - state.loading && bareShape != null -> WebPlaceholder( - host = state.host, - url = state.url, - modifier = modifier, - interactive = interactive, - onLongClick = onLongClick, - onDoubleClick = onDoubleClick, - bareShape = bareShape, - ) - - chipHost != null -> ShowPreviewChip(host = chipHost, onClick = state.ask, modifier = modifier) - } + val resolved = state.resolved ?: return + WebPreview( + preview = resolved, + url = state.url, + modifier = modifier, + interactive = interactive, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + bareShape = bareShape, + ) } @Composable @@ -204,7 +121,7 @@ private fun WebPreview( hapticFeedbackEnabled = false, ), ) { - PreviewImage(preview) + preview.photo?.let { SenderPreviewImage(it) } Column( modifier = Modifier.padding(CodeTheme.dimens.staticGrid.x2), ) { @@ -235,74 +152,9 @@ private fun WebPreview( } } -/** - * What a link-only message draws while its card loads: the card's own proportions with the page's - * parts as shimmering bars, so the real card fills the same slot. Acts as the card does -- a tap - * opens the link, a long press and a double tap go to the message -- and reads out the link itself, - * since the text it replaces is not on screen. - */ -@Composable -private fun WebPlaceholder( - host: String, - url: String, - interactive: Boolean, - onLongClick: (() -> Unit)?, - onDoubleClick: (() -> Unit)?, - bareShape: Shape, - modifier: Modifier = Modifier, -) { - val uriHandler = LocalUriHandler.current - val bar = RoundedCornerShape(CodeTheme.dimens.staticGrid.x1) - Column( - modifier = modifier - .fillMaxWidth() - .border(CodeTheme.dimens.border, BARE_STROKE, bareShape) - .clip(bareShape) - .background(PANEL_FILL) - .testTag(WEB_LINK_PLACEHOLDER_TAG) - .semantics { - contentDescription = url - linkCardShape = bareShape - } - .combinedClickable( - enabled = interactive, - onClick = { uriHandler.openUri(url) }, - onLongClick = onLongClick, - onDoubleClick = onDoubleClick, - hapticFeedbackEnabled = false, - ), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(IMAGE_ASPECT) - .shimmer(RectangleShape), - ) - Column( - modifier = Modifier.padding(CodeTheme.dimens.staticGrid.x2), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), - ) { - Text( - text = host, - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Box(Modifier.fillMaxWidth().height(TITLE_BAR_HEIGHT).shimmer(bar)) - Box(Modifier.fillMaxWidth(TITLE_BAR_SHORT_FRACTION).height(TITLE_BAR_HEIGHT).shimmer(bar)) - } - } -} - -/** - * The page's picture, through the preview loader only. With no loader, no picture: the app's own - * loader keeps cookies and follows any redirect. A picture that fails to load takes its slot with - * it, so the card closes up around the text rather than leaving a hole. - */ /** * The image of a preview the sender built: a chat photo, sealed for the chat when the message is, - * drawn through the app's own loader rather than the web one. A failure closes the slot as above. + * drawn through the app's own loader rather than the web one. A failure closes the slot, so the card closes up around the text rather than leaving a hole. */ @Composable private fun SenderPreviewImage(photo: ChatPhoto) { @@ -320,51 +172,3 @@ private fun SenderPreviewImage(photo: ChatPhoto) { .testTag(WEB_LINK_IMAGE_TAG), ) } - -@Composable -private fun PreviewImage(preview: LinkCard.Web.State.Resolved) { - preview.photo?.let { photo -> - SenderPreviewImage(photo) - return - } - val imageUrl = preview.imageUrl - val loader = LocalWebPreviewImageLoader.current ?: return - // The loader handles file and content addresses too, and the address came from a page. - if (imageUrl == null || !imageUrl.startsWith("https://")) return - var failed by remember(imageUrl) { mutableStateOf(false) } - if (failed) return - AsyncImage( - model = imageUrl, - imageLoader = loader, - contentDescription = null, - contentScale = ContentScale.Crop, - onError = { failed = true }, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(IMAGE_ASPECT) - .background(CodeTheme.colors.textSecondary.copy(alpha = 0.15f)) - .testTag(WEB_LINK_IMAGE_TAG), - ) -} - -@Composable -private fun ShowPreviewChip(host: String, onClick: () -> Unit, modifier: Modifier = Modifier) { - val shape = RoundedCornerShape(percent = 50) - Text( - text = stringResource(R.string.action_webLinkCard_showPreview, host), - style = CodeTheme.typography.caption, - color = CodeTheme.colors.textMain, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier - .padding(top = CodeTheme.dimens.staticGrid.x2) - .clip(shape) - .background(CHIP_FILL) - .combinedClickable(onClick = onClick, hapticFeedbackEnabled = false) - .padding( - horizontal = CodeTheme.dimens.staticGrid.x2, - vertical = CodeTheme.dimens.staticGrid.x1, - ) - .testTag(WEB_LINK_CHIP_TAG), - ) -} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt index 1f1e6d1c3..828666245 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebBareCardTest.kt @@ -5,12 +5,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.doubleClick -import androidx.compose.ui.test.longClick import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag @@ -24,14 +22,7 @@ import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.flipcash.shared.chat.models.LinkCard -import com.flipcash.shared.chat.models.LinkCardResolution -import com.flipcash.shared.chat.models.LocalLinkCardResolution -import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode -import com.flipcash.shared.chat.models.WebLinkPreviewMode import com.getcode.theme.DesignSystem -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -41,9 +32,8 @@ import kotlin.test.assertTrue import kotlin.time.Instant /** - * A message that is only a web link draws its resolved card with no bubble around it, in the - * bubble's corners. Anything short of a resolved card -- loading, empty, failed, the chip, the - * previews being off -- keeps the text bubble with the link, so a message never disappears. + * A message that is only a web link draws the preview its sender built with no bubble around it, in + * the bubble's corners. A link with no preview keeps the text bubble, so a message never disappears. */ @RunWith(RobolectricTestRunner::class) class WebBareCardTest { @@ -59,15 +49,6 @@ class WebBareCardTest { host = "example.com", ) - private class FakeResolution( - var answer: suspend (LinkCard) -> LinkCard, - val peeked: MutableMap = mutableMapOf(), - ) : LinkCardResolution { - override val revision = MutableStateFlow(0) - override fun peek(card: LinkCard): LinkCard? = peeked[(card as LinkCard.Web).url] - override suspend fun resolve(card: LinkCard): LinkCard = answer(card) - } - private class RecordingUriHandler : UriHandler { val opened = mutableListOf() override fun openUri(uri: String) { @@ -75,12 +56,8 @@ class WebBareCardTest { } } - private fun answering(state: LinkCard.Web.State) = - FakeResolution({ (it as LinkCard.Web).copy(state = state) }) - - private val neverAnswers get() = FakeResolution({ it }) - private fun webItem( + state: LinkCard.Web.State = resolved, text: String = url, isFromSelf: Boolean = false, isEdited: Boolean = false, @@ -96,7 +73,7 @@ class WebBareCardTest { timestamp = Instant.fromEpochSeconds(1_000), isEdited = isEdited, quote = quote, - linkCard = LinkCard.Web(url = url, start = start, end = start + url.length), + linkCard = LinkCard.Web(url = url, start = start, end = start + url.length, state = state), ) } @@ -111,20 +88,14 @@ class WebBareCardTest { private var expectedShape: Shape? = null private fun setBubble( - resolution: LinkCardResolution, item: ChatListItem.ContentBubble = webItem(), - mode: WebLinkPreviewMode = WebLinkPreviewMode.Automatic, position: BubblePosition = BubblePosition.Solo, uriHandler: UriHandler = RecordingUriHandler(), onDoubleClick: (() -> Unit)? = null, ) { composeTestRule.setContent { DesignSystem { - CompositionLocalProvider( - LocalLinkCardResolution provides resolution, - LocalWebLinkPreviewMode provides mode, - LocalUriHandler provides uriHandler, - ) { + CompositionLocalProvider(LocalUriHandler provides uriHandler) { expectedShape = bubbleShape(position, item.isFromSelf) ContentBubble( item = item, @@ -145,17 +116,6 @@ class WebBareCardTest { composeTestRule.onAllNodesWithText(url, substring = true).assertCountEquals(0) } - private fun assertPlaceholderInPlaceOfBubble() { - composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG, useUnmergedTree = true).assertIsDisplayed() - composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true).assertCountEquals(1) - composeTestRule.onAllNodesWithText(url, substring = true).assertCountEquals(0) - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG, useUnmergedTree = true).assertCountEquals(0) - } - - private fun assertNoPlaceholder() { - composeTestRule.onAllNodesWithTag(WEB_LINK_PLACEHOLDER_TAG, useUnmergedTree = true).assertCountEquals(0) - } - private fun assertInTextBubble() { composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true).assertCountEquals(0) composeTestRule.onNodeWithText(url, substring = true).assertIsDisplayed() @@ -163,7 +123,7 @@ class WebBareCardTest { @Test fun `a link-only message with a resolved card draws the card alone in the bubble's corners`() { - setBubble(answering(resolved), position = BubblePosition.First) + setBubble(position = BubblePosition.First) composeTestRule.waitForIdle() assertBare() @@ -173,190 +133,9 @@ class WebBareCardTest { assertEquals(expectedShape, shape) } - @Test - fun `a card still loading holds its place with a placeholder and no link text`() { - setBubble(neverAnswers, position = BubblePosition.First) - - composeTestRule.waitForIdle() - assertPlaceholderInPlaceOfBubble() - val node = composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG, useUnmergedTree = true) - .fetchSemanticsNode() - assertEquals(listOf(url), node.config[SemanticsProperties.ContentDescription]) - assertEquals(expectedShape, node.config[LinkCardShapeKey]) - // The host comes from the link that was sent, without its "www.". - composeTestRule.onNodeWithText("example.com").assertIsDisplayed() - } - - @Test - fun `a loading card that resolves swaps to the card in place`() { - val gate = CompletableDeferred() - setBubble(FakeResolution({ gate.await(); (it as LinkCard.Web).copy(state = resolved) })) - composeTestRule.waitForIdle() - assertPlaceholderInPlaceOfBubble() - - val placeholderBounds = composeTestRule - .onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() - - gate.complete(Unit) - composeTestRule.waitForIdle() - - assertBare() - assertNoPlaceholder() - // Same slot: the card takes the width and the corner of the panel it replaces. - val cardBounds = composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).getUnclippedBoundsInRoot() - assertEquals(placeholderBounds.right - placeholderBounds.left, cardBounds.right - cardBounds.left) - assertEquals(placeholderBounds.left, cardBounds.left) - } - - @Test - fun `a loading card that comes back empty falls back to the text bubble with the link`() { - val gate = CompletableDeferred() - setBubble(FakeResolution({ gate.await(); (it as LinkCard.Web).copy(state = LinkCard.Web.State.None) })) - composeTestRule.waitForIdle() - assertPlaceholderInPlaceOfBubble() - - gate.complete(Unit) - composeTestRule.waitForIdle() - - assertInTextBubble() - assertNoPlaceholder() - } - - @Test - fun `an answer held as empty draws the text bubble on the first frame with no placeholder`() { - val resolution = neverAnswers - resolution.peeked[url] = LinkCard.Web(url, 0, url.length, LinkCard.Web.State.None) - composeTestRule.mainClock.autoAdvance = false - - setBubble(resolution) - - assertInTextBubble() - assertNoPlaceholder() - } - - @Test - fun `text beside a loading link keeps the text bubble and no placeholder`() { - setBubble(neverAnswers, item = webItem(text = "look at $url now")) - - composeTestRule.waitForIdle() - assertInTextBubble() - assertNoPlaceholder() - } - - @Test - fun `a placeholder opens the link on a single tap after the double-tap window`() { - val handler = RecordingUriHandler() - setBubble(neverAnswers, uriHandler = handler, onDoubleClick = {}) - composeTestRule.waitForIdle() - - composeTestRule.mainClock.autoAdvance = false - composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG).performClick() - composeTestRule.mainClock.advanceTimeBy(1_000) - - assertEquals(listOf(url), handler.opened) - } - - @Test - fun `a double tap on the placeholder reacts and opens nothing`() { - val handler = RecordingUriHandler() - var reacts = 0 - setBubble(neverAnswers, uriHandler = handler, onDoubleClick = { reacts++ }) - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG).performTouchInput { doubleClick() } - composeTestRule.waitForIdle() - - assertEquals(1, reacts) - assertEquals(emptyList(), handler.opened) - } - - @Test - fun `a long press on the placeholder acts on the message`() { - var longPresses = 0 - composeTestRule.setContent { - DesignSystem { - CompositionLocalProvider( - LocalLinkCardResolution provides neverAnswers, - LocalWebLinkPreviewMode provides WebLinkPreviewMode.Automatic, - ) { - ContentBubble(item = webItem(), position = BubblePosition.Solo, onLongClick = { longPresses++ }) - } - } - } - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithTag(WEB_LINK_PLACEHOLDER_TAG).performTouchInput { longClick() } - composeTestRule.waitForIdle() - - assertEquals(1, longPresses) - } - - @Test - fun `the chip shows no placeholder, and tapping it holds the place with one`() { - setBubble(neverAnswers, mode = WebLinkPreviewMode.TapToLoad) - composeTestRule.waitForIdle() - assertInTextBubble() - assertNoPlaceholder() - composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).assertIsDisplayed() - - composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() - composeTestRule.waitForIdle() - - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) - assertPlaceholderInPlaceOfBubble() - } - - @Test - fun `with previews off a loading link stays a plain text bubble with no placeholder`() { - setBubble(neverAnswers, mode = WebLinkPreviewMode.Off) - - composeTestRule.waitForIdle() - assertInTextBubble() - assertNoPlaceholder() - } - - @Test - fun `an empty answer keeps the text bubble with the link`() { - setBubble(answering(LinkCard.Web.State.None)) - - composeTestRule.waitForIdle() - assertInTextBubble() - } - - @Test - fun `the tap-to-load chip keeps the text bubble with the link`() { - setBubble(neverAnswers, mode = WebLinkPreviewMode.TapToLoad) - - composeTestRule.waitForIdle() - assertInTextBubble() - composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).assertIsDisplayed() - } - - @Test - fun `a fetch that failed after the chip keeps the text bubble with the link`() { - setBubble(answering(LinkCard.Web.State.None), mode = WebLinkPreviewMode.TapToLoad) - - composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() - composeTestRule.waitForIdle() - - assertInTextBubble() - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) - } - - @Test - fun `with previews off the message stays a plain text bubble`() { - val resolution = answering(resolved) - resolution.peeked[url] = LinkCard.Web(url, 0, url.length, resolved) - setBubble(resolution, mode = WebLinkPreviewMode.Off) - - composeTestRule.waitForIdle() - assertInTextBubble() - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) - } - @Test fun `text beside the link keeps the card inside the bubble under the text`() { - setBubble(answering(resolved), item = webItem(text = "look at $url now")) + setBubble(item = webItem(text = "look at $url now")) composeTestRule.waitForIdle() assertInTextBubble() @@ -365,49 +144,20 @@ class WebBareCardTest { @Test fun `a link wrapped in punctuation counts as the link alone`() { - setBubble(answering(resolved), item = webItem(text = "($url).")) + setBubble(item = webItem(text = "($url).")) composeTestRule.waitForIdle() composeTestRule.onAllNodes(drawnBare, useUnmergedTree = true).assertCountEquals(1) composeTestRule.onAllNodesWithText(url, substring = true).assertCountEquals(0) } - @Test - fun `a card already held is drawn bare on the first frame with no bubble before it`() { - // The lookup never answers, so the held answer is all the first frame has. - val resolution = neverAnswers - resolution.peeked[url] = LinkCard.Web(url, 0, url.length, resolved) - composeTestRule.mainClock.autoAdvance = false - - setBubble(resolution) - - assertBare() - composeTestRule.onNodeWithText("The title").assertIsDisplayed() - } - - @Test - fun `a held answer that expires and comes back empty returns to the text bubble`() { - val resolution = answering(resolved) - resolution.peeked[url] = LinkCard.Web(url, 0, url.length, resolved) - setBubble(resolution) - composeTestRule.waitForIdle() - assertBare() - - resolution.peeked.clear() - resolution.answer = { (it as LinkCard.Web).copy(state = LinkCard.Web.State.None) } - resolution.revision.value += 1 - composeTestRule.waitForIdle() - - assertInTextBubble() - } - @Test fun `a link-only reply keeps its citation above the bare card`() { val item = webItem( content = MessageContent.Reply(repliedMessageId = 9, content = listOf(MessageContent.Text(url))), quote = reply, ) - setBubble(answering(resolved), item = item) + setBubble(item = item) composeTestRule.waitForIdle() assertBare() @@ -420,7 +170,7 @@ class WebBareCardTest { fun `a double tap on the bare card reacts and opens nothing`() { val handler = RecordingUriHandler() var reacts = 0 - setBubble(answering(resolved), uriHandler = handler, onDoubleClick = { reacts++ }) + setBubble(uriHandler = handler, onDoubleClick = { reacts++ }) composeTestRule.waitForIdle() assertBare() @@ -436,7 +186,6 @@ class WebBareCardTest { val handler = RecordingUriHandler() var reacts = 0 setBubble( - answering(resolved), item = webItem(text = "look at $url now"), uriHandler = handler, onDoubleClick = { reacts++ }, @@ -453,7 +202,7 @@ class WebBareCardTest { @Test fun `a single tap on the bare card opens the page after the double-tap window`() { val handler = RecordingUriHandler() - setBubble(answering(resolved), uriHandler = handler, onDoubleClick = {}) + setBubble(uriHandler = handler, onDoubleClick = {}) composeTestRule.waitForIdle() composeTestRule.mainClock.autoAdvance = false @@ -465,61 +214,33 @@ class WebBareCardTest { } @Test - fun `the tap-to-load chip acts on the first tap and takes no double tap`() { - var reacts = 0 - setBubble(neverAnswers, mode = WebLinkPreviewMode.TapToLoad, onDoubleClick = { reacts++ }) - composeTestRule.waitForIdle() + fun `a link-only message with no preview keeps the text bubble with the link`() { + setBubble(item = webItem(state = LinkCard.Web.State.Loading)) - composeTestRule.mainClock.autoAdvance = false - composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() - composeTestRule.mainClock.advanceTimeBy(1_000) - composeTestRule.mainClock.autoAdvance = true composeTestRule.waitForIdle() - - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) - assertEquals(0, reacts) + assertInTextBubble() + composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) } @Test fun `an edited bare web card is told to draw its marker below`() { var bare: Boolean? = null - val resolution = answering(resolved) composeTestRule.setContent { - DesignSystem { - CompositionLocalProvider(LocalLinkCardResolution provides resolution) { - bare = webItem(isEdited = true).rememberRendersBare() - } - } + DesignSystem { bare = webItem(isEdited = true).rememberRendersBare() } } composeTestRule.waitForIdle() assertEquals(true, bare) } @Test - fun `a web card that came back empty does not count as bare`() { + fun `a web card with no preview does not count as bare`() { var bare: Boolean? = null composeTestRule.setContent { DesignSystem { - CompositionLocalProvider(LocalLinkCardResolution provides answering(LinkCard.Web.State.None)) { - bare = webItem(isEdited = true).rememberRendersBare() - } + bare = webItem(state = LinkCard.Web.State.None, isEdited = true).rememberRendersBare() } } composeTestRule.waitForIdle() assertEquals(false, bare) } - - @Test - fun `a web card still loading counts as bare so its marker clears the placeholder`() { - var bare: Boolean? = null - composeTestRule.setContent { - DesignSystem { - CompositionLocalProvider(LocalLinkCardResolution provides neverAnswers) { - bare = webItem(isEdited = true).rememberRendersBare() - } - } - } - composeTestRule.waitForIdle() - assertEquals(true, bare) - } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardImageFirstFrameTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardImageFirstFrameTest.kt deleted file mode 100644 index 4b726ab47..000000000 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardImageFirstFrameTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.flipcash.shared.chat.ui - -import android.graphics.Bitmap -import android.graphics.Color as AndroidColor -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toPixelMap -import androidx.compose.ui.test.captureToImage -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.unit.dp -import androidx.test.core.app.ApplicationProvider -import coil3.ImageLoader -import coil3.Uri -import coil3.asImage -import coil3.annotation.ExperimentalCoilApi -import coil3.decode.DataSource -import coil3.fetch.FetchResult -import coil3.fetch.Fetcher -import coil3.fetch.ImageFetchResult -import coil3.request.ImageRequest -import coil3.request.Options -import coil3.size.Size -import com.flipcash.shared.chat.models.LinkCard -import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader -import com.getcode.theme.DesignSystem -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.runBlocking -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.util.concurrent.atomic.AtomicInteger -import kotlin.test.assertEquals - -/** - * P22a: a picture already in the loader's memory cache is on the card's first frame, with no - * wait on a fetch. The fetcher serves the picture once, to warm the cache, and hangs after that, - * so anything drawn can only have come from memory. - */ -@OptIn(ExperimentalCoilApi::class) -@RunWith(RobolectricTestRunner::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(sdk = [34], qualifiers = "w402dp-h600dp-xhdpi") -class WebCardImageFirstFrameTest { - - @get:Rule - val composeRule = createAndroidComposeRule() - - private val imageUrl = "https://img.example.com/p.png" - private val fetches = AtomicInteger() - - private val factory = object : Fetcher.Factory { - override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher = Fetcher { - if (fetches.incrementAndGet() > 1) awaitCancellation() - val bitmap = Bitmap.createBitmap(1200, 628, Bitmap.Config.ARGB_8888).apply { - eraseColor(AndroidColor.RED) - } - ImageFetchResult(bitmap.asImage(), isSampled = false, dataSource = DataSource.NETWORK) as FetchResult - } - } - - @Test - fun `an image already in memory is drawn on the card's first frame`() { - val context = ApplicationProvider.getApplicationContext() - val loader = ImageLoader.Builder(context) - .components { add(factory) } - .diskCache(null) - .build() - runBlocking { loader.execute(ImageRequest.Builder(context).data(imageUrl).size(Size.ORIGINAL).build()) } - assertEquals(1, fetches.get(), "the warm-up should have fetched once") - - val state = WebLinkCardState( - url = "https://www.example.com/a", - resolved = LinkCard.Web.State.Resolved("The title", "A description", imageUrl, "example.com"), - chipHost = null, - loading = false, - host = "example.com", - ask = {}, - ) - composeRule.mainClock.autoAdvance = false - composeRule.setContent { - DesignSystem { - CompositionLocalProvider(LocalWebPreviewImageLoader provides loader) { - Box(Modifier.width(300.dp)) { WebLinkCard(state = state) } - } - } - } - composeRule.mainClock.advanceTimeByFrame() - - val pixels = composeRule.onNodeWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true) - .captureToImage().toPixelMap() - val centre = pixels[pixels.width / 2, pixels.height / 2] - assertEquals(1f, centre.red, 0.02f, "the picture should be drawn, not the placeholder tint") - assertEquals(0f, centre.green, 0.02f) - } -} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardOutlineTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardOutlineTest.kt index 53489bee2..19edae3dc 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardOutlineTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebCardOutlineTest.kt @@ -45,14 +45,7 @@ class WebCardOutlineTest { host = "example.com", ) - private fun state(resolved: LinkCard.Web.State.Resolved?, loading: Boolean) = WebLinkCardState( - url = url, - resolved = resolved, - chipHost = null, - loading = loading, - host = "example.com", - ask = {}, - ) + private fun state(resolved: LinkCard.Web.State.Resolved) = WebLinkCardState(url = url, resolved = resolved) /** * The pixel at mid width on the panel's bottom row, and one a few rows above it. Both sit on the @@ -72,27 +65,20 @@ class WebCardOutlineTest { } } repeat(5) { composeRule.mainClock.advanceTimeByFrame() } - val tag = if (state.loading) WEB_LINK_PLACEHOLDER_TAG else WEB_LINK_CARD_TAG - val pixels = composeRule.onNodeWithTag(tag).captureToImage().toPixelMap() + val pixels = composeRule.onNodeWithTag(WEB_LINK_CARD_TAG).captureToImage().toPixelMap() val x = pixels.width / 2 return pixels[x, pixels.height - 1] to pixels[x, pixels.height - 4] } @Test fun `a bare resolved card draws an edge unlike its panel`() { - val (edge, inside) = edgeAndInside(state(resolved, loading = false), bare = true) - assertNotEquals(inside, edge, "the bottom row should be the outline, not the panel") - } - - @Test - fun `a bare placeholder draws the same edge`() { - val (edge, inside) = edgeAndInside(state(null, loading = true), bare = true) + val (edge, inside) = edgeAndInside(state(resolved), bare = true) assertNotEquals(inside, edge, "the bottom row should be the outline, not the panel") } @Test fun `a card inside a bubble draws no outline`() { - val (edge, inside) = edgeAndInside(state(resolved, loading = false), bare = false) + val (edge, inside) = edgeAndInside(state(resolved), bare = false) assertEquals(inside, edge, "an in-bubble card's bottom row should be its panel") } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt index dbda333c2..bbb452ff9 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/WebLinkCardTest.kt @@ -1,47 +1,27 @@ package com.flipcash.shared.chat.ui -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.performClick -import androidx.compose.ui.unit.dp -import coil3.ImageLoader -import coil3.annotation.ExperimentalCoilApi -import coil3.fetch.FetchResult -import coil3.fetch.Fetcher -import coil3.request.Options import com.flipcash.shared.chat.models.LinkCard -import com.flipcash.shared.chat.models.LinkCardResolution -import com.flipcash.shared.chat.models.LocalLinkCardResolution -import com.flipcash.shared.chat.models.LocalWebLinkPreviewMode -import com.flipcash.shared.chat.models.LocalWebPreviewImageLoader -import com.flipcash.shared.chat.models.WebLinkPreviewMode import com.getcode.theme.DesignSystem -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import java.io.IOException -import java.util.concurrent.atomic.AtomicInteger import kotlin.test.assertEquals /** - * What a web card draws and when it asks. Loading and an empty answer draw nothing, a viewer outside - * the group asks only when told to, and the whole card opens the page through the host's handler. + * What a web card draws: the preview its sender built and nothing else. A card with no preview + * draws nothing, and the whole card opens the page through the host's handler. */ @RunWith(RobolectricTestRunner::class) class WebLinkCardTest { @@ -57,49 +37,22 @@ class WebLinkCardTest { host = "example.com", ) - private class FakeResolution(private val answer: (LinkCard) -> LinkCard) : LinkCardResolution { - val calls = AtomicInteger() - val peeked: MutableMap = mutableMapOf() - override val revision: StateFlow = MutableStateFlow(0) - override fun peek(card: LinkCard): LinkCard? = peeked[(card as LinkCard.Web).url] - override suspend fun resolve(card: LinkCard): LinkCard { - calls.incrementAndGet() - return answer(card) - } - } - private fun card(state: LinkCard.Web.State = LinkCard.Web.State.Loading) = LinkCard.Web(url = url, start = 0, end = url.length, state = state) - private fun resolving(state: LinkCard.Web.State) = - FakeResolution { (it as LinkCard.Web).copy(state = state) } - - private fun setCard( - resolution: FakeResolution, - mode: WebLinkPreviewMode = WebLinkPreviewMode.Automatic, - uriHandler: UriHandler = RecordingUriHandler(), - imageLoader: ImageLoader? = null, - onLongClick: (() -> Unit)? = null, - seed: LinkCard.Web = card(), - ) { + private fun setCard(seed: LinkCard.Web, uriHandler: UriHandler = RecordingUriHandler()) { composeTestRule.setContent { DesignSystem { - CompositionLocalProvider( - LocalLinkCardResolution provides resolution, - LocalWebLinkPreviewMode provides mode, - LocalWebPreviewImageLoader provides imageLoader, - LocalUriHandler provides uriHandler, - ) { - Card(onLongClick, seed) + CompositionLocalProvider(LocalUriHandler provides uriHandler) { + Card(seed) } } } } @Composable - private fun Card(onLongClick: (() -> Unit)?, seed: LinkCard.Web = card()) { - val state = rememberWebLinkCard(seed) - WebLinkCard(state = state, onLongClick = onLongClick, onDoubleClick = null) + private fun Card(seed: LinkCard.Web) { + WebLinkCard(state = rememberWebLinkCard(seed), onLongClick = null, onDoubleClick = null) } private class RecordingUriHandler : UriHandler { @@ -110,227 +63,35 @@ class WebLinkCardTest { } @Test - fun `a resolved card draws its title description and host`() { - setCard(resolving(resolved)) + fun `a preview the sender built draws its title description and host`() { + setCard(card(resolved)) composeTestRule.onNodeWithText("The title").assertIsDisplayed() composeTestRule.onNodeWithText("A description").assertIsDisplayed() composeTestRule.onNodeWithText("example.com").assertIsDisplayed() - composeTestRule.onAllNodesWithText("og:site_name").assertCountEquals(0) } @Test - fun `a preview the sender built draws outside the group with no tap and nothing asked`() { - val resolution = resolving(LinkCard.Web.State.None) - setCard(resolution, mode = WebLinkPreviewMode.TapToLoad, seed = card(resolved)) + fun `a card still loading draws nothing`() { + setCard(card(LinkCard.Web.State.Loading)) - composeTestRule.waitForIdle() - assertEquals(0, resolution.calls.get()) - composeTestRule.onNodeWithText("The title").assertIsDisplayed() - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) - } - - @Test - fun `with previews off a preview the sender built draws nothing`() { - val resolution = resolving(resolved) - setCard(resolution, mode = WebLinkPreviewMode.Off, seed = card(resolved)) - - composeTestRule.waitForIdle() - assertEquals(0, resolution.calls.get()) composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) } @Test - fun `an empty answer draws nothing`() { - setCard(resolving(LinkCard.Web.State.None)) + fun `a card with an empty answer draws nothing`() { + setCard(card(LinkCard.Web.State.None)) - composeTestRule.waitForIdle() composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) } @Test - fun `a card still loading in automatic mode draws nothing`() { - // The lookup never answers in this run: the card stays in its loading state. - setCard(FakeResolution { it }) - - composeTestRule.waitForIdle() - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) - } - - @Test - fun `outside the group nothing is asked until the chip is tapped and then it is asked once`() { - val resolution = resolving(resolved) - setCard(resolution, mode = WebLinkPreviewMode.TapToLoad) - - composeTestRule.waitForIdle() - assertEquals(0, resolution.calls.get()) - // The host reads without its www. - composeTestRule.onNodeWithText("Show preview · example.com").assertIsDisplayed() - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) - - composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() - composeTestRule.waitForIdle() - - assertEquals(1, resolution.calls.get()) - composeTestRule.onNodeWithText("The title").assertIsDisplayed() - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) - } - - @Test - fun `a fetch that fails after the chip is tapped shows nothing and no chip`() { - val resolution = FakeResolution { it } - setCard(resolution, mode = WebLinkPreviewMode.TapToLoad) - - composeTestRule.onNodeWithTag(WEB_LINK_CHIP_TAG).performClick() - composeTestRule.waitForIdle() - - assertEquals(1, resolution.calls.get()) - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) - } - - @Test - fun `an answer already held is drawn outside the group without a tap`() { - val resolution = FakeResolution { it } - resolution.peeked[url] = card(resolved) - setCard(resolution, mode = WebLinkPreviewMode.TapToLoad) - - composeTestRule.waitForIdle() - assertEquals(0, resolution.calls.get()) - composeTestRule.onNodeWithText("The title").assertIsDisplayed() - } - - @Test - fun `with previews off a non-member sees no chip and nothing is asked`() { - val resolution = resolving(resolved) - setCard(resolution, mode = WebLinkPreviewMode.Off) - - composeTestRule.waitForIdle() - assertEquals(0, resolution.calls.get()) - composeTestRule.onAllNodesWithTag(WEB_LINK_CHIP_TAG).assertCountEquals(0) - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) - } - - @Test - fun `with previews off no card is drawn in place of automatic one nor from a held answer`() { - val resolution = resolving(resolved) - resolution.peeked[url] = card(resolved) - setCard(resolution, mode = WebLinkPreviewMode.Off) - - composeTestRule.waitForIdle() - assertEquals(0, resolution.calls.get()) - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) - composeTestRule.onAllNodesWithText("The title").assertCountEquals(0) - } - - @Test - fun `flipping previews on while the message is drawn brings the card back`() { - val resolution = resolving(resolved) - val mode = mutableStateOf(WebLinkPreviewMode.Off) - composeTestRule.setContent { - DesignSystem { - CompositionLocalProvider( - LocalLinkCardResolution provides resolution, - LocalWebLinkPreviewMode provides mode.value, - LocalUriHandler provides RecordingUriHandler(), - ) { - Card(onLongClick = null) - } - } - } - composeTestRule.waitForIdle() - composeTestRule.onAllNodesWithTag(WEB_LINK_CARD_TAG).assertCountEquals(0) - - mode.value = WebLinkPreviewMode.Automatic - composeTestRule.waitForIdle() - - composeTestRule.onNodeWithText("The title").assertIsDisplayed() - } - - @Test - fun `a click on a resolved card opens the card url`() { + fun `a click on the card opens the card url`() { val handler = RecordingUriHandler() - setCard(resolving(resolved), uriHandler = handler) + setCard(card(resolved), uriHandler = handler) composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).performClick() assertEquals(listOf(url), handler.opened) } - - @Test - fun `an image that fails to load drops its slot and keeps the text`() { - val loader = ImageLoader.Builder(androidx.test.core.app.ApplicationProvider.getApplicationContext()) - .components { add(FailingFetcherFactory) } - .build() - setCard( - resolving(resolved.copy(imageUrl = "https://img.example.com/p.png")), - imageLoader = loader, - ) - - composeTestRule.waitUntil(timeoutMillis = 5_000) { - composeTestRule.onAllNodesWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && - composeTestRule.onAllNodesWithText("The title").fetchSemanticsNodes().isNotEmpty() - } - composeTestRule.onNodeWithText("The title").assertIsDisplayed() - composeTestRule.onNodeWithText("example.com").assertIsDisplayed() - } - - @Test - fun `the text sits inside the panel by ten dp and the picture meets its top and sides`() { - val loader = ImageLoader.Builder(androidx.test.core.app.ApplicationProvider.getApplicationContext()) - .components { add(HangingFetcherFactory) } - .build() - setCard( - resolving(resolved.copy(imageUrl = "https://img.example.com/p.png")), - imageLoader = loader, - ) - - val panel = composeTestRule.onNodeWithTag(WEB_LINK_CARD_TAG).getUnclippedBoundsInRoot() - val image = composeTestRule.onNodeWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true).getUnclippedBoundsInRoot() - val host = composeTestRule.onNodeWithText("example.com", useUnmergedTree = true).getUnclippedBoundsInRoot() - val description = composeTestRule.onNodeWithText("A description", useUnmergedTree = true).getUnclippedBoundsInRoot() - - assertEquals(panel.top, image.top) - assertEquals(panel.left, image.left) - assertEquals(panel.right, image.right) - assertEquals(image.bottom + 10.dp, host.top) - assertEquals(panel.left + 10.dp, host.left) - assertEquals(panel.bottom - 10.dp, description.bottom) - } - - @Test - fun `a picture still loading keeps its slot`() { - val loader = ImageLoader.Builder(androidx.test.core.app.ApplicationProvider.getApplicationContext()) - .components { add(HangingFetcherFactory) } - .build() - setCard( - resolving(resolved.copy(imageUrl = "https://img.example.com/p.png")), - imageLoader = loader, - ) - - composeTestRule.onNodeWithText("The title").assertIsDisplayed() - composeTestRule.onAllNodesWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true).assertCountEquals(1) - } - - @Test - fun `no picture is asked for without the preview loader`() { - setCard(resolving(resolved.copy(imageUrl = "https://img.example.com/p.png"))) - - composeTestRule.onNodeWithText("The title").assertIsDisplayed() - composeTestRule.onAllNodesWithTag(WEB_LINK_IMAGE_TAG, useUnmergedTree = true).assertCountEquals(0) - } - - @OptIn(ExperimentalCoilApi::class) - private object HangingFetcherFactory : Fetcher.Factory { - override fun create(data: coil3.Uri, options: Options, imageLoader: ImageLoader): Fetcher = - Fetcher { awaitCancellation() } - } - - @OptIn(ExperimentalCoilApi::class) - private object FailingFetcherFactory : Fetcher.Factory { - override fun create(data: coil3.Uri, options: Options, imageLoader: ImageLoader): Fetcher = - Fetcher { throw IOException("no image") } - } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt deleted file mode 100644 index 550bd6ab0..000000000 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.flipcash.shared.chat - -import com.flipcash.services.models.chat.ChatMessage -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -/** - * Resolves the links in messages as they arrive, so a card is drawn with its answer on the frame - * its message first appears rather than growing into it a round trip later. - * - * A card that changes size when its lookup lands moves every message above it, because the - * transcript is anchored at the bottom. Asking before the write is what takes that jump away: by - * the time the row reaches the transcript, the answer is already held. - * - * The lookups live in `:apps:flipcash:features:messenger`, which this module cannot see, so this is - * the seam; `MessageLinkPrefetcher` on the far side is the implementation. - * - * [webLinks] lets outside https links be fetched too. Only a caller that knows the messages are in - * the chat on screen, and that the viewer is a member of it, passes true. A web fetch is visible - * to the linked host, so it must happen no earlier than the read receipt it stands beside. A feed - * sync, a delivery or a push never passes it. - */ -interface MessageLinkPrefetch { - - /** - * Starts resolving every link card in [messages] that has no answer held yet, and suspends - * until they have all answered or [wait] has passed, whichever is first. - * - * The lookups outlive the wait. A link slower than [wait] still lands in the store, and its - * card draws the answer from there; only the message's write stops waiting for it. A [wait] - * of zero starts the lookups and returns at once. - * - * Never throws: a lookup that fails leaves the card to ask for itself when drawn. - */ - suspend fun prefetch(messages: List, wait: Duration = Duration.ZERO, webLinks: Boolean = false) - - /** Does nothing. What a delegate built without a prefetcher -- a unit test -- is given. */ - object None : MessageLinkPrefetch { - override suspend fun prefetch(messages: List, wait: Duration, webLinks: Boolean) = Unit - } - - companion object { - /** - * How long a live message's write waits for its links. Long enough for one lookup on a - * healthy connection; short enough that a slow one costs the message a beat rather than - * holding it back. - */ - val LIVE_WAIT: Duration = 1.seconds - } -} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt deleted file mode 100644 index 68ac813c8..000000000 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.flipcash.shared.chat.internal - -import com.flipcash.app.persistence.sources.ChatMetadataDataSource -import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatType - -/** - * Whether a chat's web links may be fetched now: it is the chat on screen, and the viewer may read - * it. `activeChat` is null while the app is backgrounded, so a push that arrives then is refused. - * - * A DM is always allowed and its membership is not read. Only a group has non-members who can be - * looking at a preview, so only a group is checked. A chat whose type is not stored yet is refused: - * it may be a group. - */ -internal class WebPreviewGate( - private val stateHolder: ChatStateHolder, - private val metadata: ChatMetadataDataSource, -) { - suspend fun allows(chatId: ChatId): Boolean { - if (stateHolder.current.activeChat != chatId) return false - return when (metadata.getChatType(chatId)) { - ChatType.CONTACT_DM, ChatType.TIP_DM -> true - ChatType.GROUP -> metadata.isMember(chatId) - ChatType.UNKNOWN -> false - } - } -} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 5859f26b7..acecaaaca 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -28,9 +28,7 @@ import com.flipcash.shared.chat.EventSequenceTracker import com.flipcash.shared.chat.EventStreamOperations import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.MessageLinkPrefetch import com.flipcash.shared.chat.internal.MentionPoolUpdates -import com.flipcash.shared.chat.internal.WebPreviewGate import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceType @@ -94,11 +92,9 @@ class EventStreamDelegate @Inject constructor( private val stateHolder: ChatStateHolder, private val analytics: FlipcashAnalytics, private val exchange: Exchange, - private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None, ) : EventStreamOperations { - private val webGate = WebPreviewGate(stateHolder, metadataDataSource) companion object { private const val TAG = "EventStreamDelegate" @@ -300,7 +296,6 @@ class EventStreamDelegate @Inject constructor( result .onSuccess { delta -> if (delta.messages.isNotEmpty()) { - linkPrefetch.prefetch(delta.messages, MessageLinkPrefetch.LIVE_WAIT, webLinks = webGate.allows(chatId)) messageDataSource.upsert(chatId, delta.messages) } // One write that only moves forward: the cursor, and the newest message only @@ -361,8 +356,6 @@ class EventStreamDelegate @Inject constructor( val lastMsg = if (resolvedMessages.isNotEmpty()) { trace(tag = TAG, message = "Upserting ${resolvedMessages.size} messages for $chatId", type = TraceType.Process) - // Waited on so a link card lands already sized; see [MessageLinkPrefetch]. - linkPrefetch.prefetch(resolvedMessages, MessageLinkPrefetch.LIVE_WAIT, webLinks = webGate.allows(update.chatId)) messageDataSource.upsert(chatId, resolvedMessages) typingTracker.messageArrived(chatId, resolvedMessages.mapNotNull { it.senderId }) mentionPool.onMessages(chatId, resolvedMessages.mapNotNull { m -> m.senderId?.let { it to m.timestamp } }) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 2ee87c8b9..d605123b3 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -13,7 +13,6 @@ import com.flipcash.app.persistence.sources.ChatFeedWriter import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource -import com.flipcash.app.persistence.sources.lastMessagesByChat import com.flipcash.app.persistence.sources.mediator.ChatFeedRemoteMediator import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController @@ -35,7 +34,6 @@ import com.flipcash.shared.chat.internal.isRenderable import com.flipcash.shared.chat.internal.selfReadPointer import com.flipcash.shared.chat.internal.unreadCount import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.MessageLinkPrefetch import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceType import com.getcode.utils.trace @@ -86,12 +84,10 @@ class FeedSyncDelegate @Inject constructor( private val stateHolder: ChatStateHolder, private val userManager: UserManager, private val messagingController: ChatMessagingController, - private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, private val feedWriter: ChatFeedWriter = ChatFeedWriter(metadataDataSource, memberDataSource, messageDataSource), ) : FeedOperations { - companion object { private const val TAG = "FeedSyncDelegate" @@ -534,13 +530,10 @@ class FeedSyncDelegate @Inject constructor( /** * Writes [chats] — whatever mix of DM and group chats the caller has in hand — in one - * transaction, so the list rebuilds once. Link prefetch and read stamps go first: neither - * blocks the list, and the stamps ride in with the rebuild. + * transaction, so the list rebuilds once. Read stamps go first: they do not block the list, and ride in with the rebuild. */ internal suspend fun writeFeed(chats: List) { if (chats.isEmpty()) return - // Not waited on: a preview is one message per chat, and the feed is not held for it. - linkPrefetch.prefetch(chats.lastMessagesByChat().values.flatten()) stageReadStamps(chats) feedWriter.write(chats) } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index 1609bbff4..e6bab1478 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -4,7 +4,6 @@ import com.flipcash.app.persistence.sources.ChatFeedWriter import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource -import com.flipcash.app.persistence.sources.lastMessagesByChat import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata @@ -15,7 +14,6 @@ import com.flipcash.services.models.chat.RosterChange import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.GroupOperations -import com.flipcash.shared.chat.MessageLinkPrefetch import com.flipcash.shared.chat.internal.RosterStateHolder import com.getcode.utils.TraceType import com.getcode.utils.trace @@ -52,7 +50,6 @@ class GroupFeedDelegate @Inject constructor( private val messageDataSource: ChatMessageDataSource, private val rosterStateHolder: RosterStateHolder, private val userManager: UserManager, - private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, private val feedWriter: ChatFeedWriter = ChatFeedWriter(metadataDataSource, memberDataSource, messageDataSource), ) : GroupOperations { @@ -254,8 +251,6 @@ class GroupFeedDelegate @Inject constructor( } private suspend fun persist(chats: List) { - // Not waited on: a preview is one message per chat, and the feed is not held for it. - linkPrefetch.prefetch(chats.lastMessagesByChat().values.flatten()) feedWriter.write(chats) } 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 d15b46373..c44a97183 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 @@ -49,11 +49,9 @@ import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.OutgoingEncryption -import com.flipcash.shared.chat.internal.WebPreviewGate import com.flipcash.shared.chat.media.ChatMediaText import com.flipcash.shared.chat.replacingText import com.flipcash.services.user.UserManager -import com.flipcash.shared.chat.MessageLinkPrefetch import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceType import com.getcode.utils.trace @@ -104,15 +102,12 @@ class MessagingDelegate @Inject constructor( private val stateHolder: ChatStateHolder, private val analytics: FlipcashAnalytics, private val senderResolver: SenderResolver, - private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, private val outgoing: OutgoingEncryption = OutgoingEncryption.None, /** Opens encrypted pushes; without it, every push keeps the server's body. */ private val incoming: IncomingMessageOpener? = null, private val previewImages: LinkPreviewImages = LinkPreviewImages.None, ) : MessagingOperations { - private val webGate = WebPreviewGate(stateHolder, metadataDataSource) - /** * Edits and deletes awaiting a server answer, per chat, keyed by message id. * @@ -327,8 +322,6 @@ class MessagingDelegate @Inject constructor( override suspend fun loadMessages(chatId: ChatId) { messagingController.getMessages(chatId) .onSuccess { messages -> - // Not waited on: this page is what the open transcript is waiting for. - linkPrefetch.prefetch(messages, webLinks = webGate.allows(chatId)) messageDataSource.upsert(chatId, messages) // Seat the event-log cursor at the newest page's frontier. It is what marks this @@ -346,7 +339,6 @@ class MessagingDelegate @Inject constructor( override suspend fun applyPushedMessage(chatId: ChatId, message: ChatMessage) { // The DAO's upsert already drops a copy older than the stored row, so the only // ordering this has to protect is the metadata below it. - linkPrefetch.prefetch(listOf(message), MessageLinkPrefetch.LIVE_WAIT) messageDataSource.upsert(chatId, listOf(message)) // Deliberately not advancing the event-log cursor. A push carries one message, not diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingWebPrefetchTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingWebPrefetchTest.kt deleted file mode 100644 index c9cac2d94..000000000 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingWebPrefetchTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.flipcash.shared.chat - -import com.flipcash.app.persistence.sources.ChatMetadataDataSource -import com.flipcash.services.controllers.ChatMessagingController -import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatMessage -import com.flipcash.services.models.chat.ChatType -import com.flipcash.services.models.chat.MessageContent -import com.flipcash.shared.chat.internal.ChatStateHolder -import com.flipcash.shared.chat.internal.delegates.MessagingDelegate -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.time.Duration -import kotlin.time.Instant - -/** - * Which page loads may fetch outside links. Every load still prefetches, so group, person and gift - * cards resolve ahead as before; only the `webLinks` grant depends on the chat. - */ -class MessagingWebPrefetchTest { - - private val chatId = ChatId("aabbccdd") - private val page = ChatMessage( - messageId = 1, - senderId = listOf(4, 5, 6), - content = listOf(MessageContent.Text("https://example.com/a")), - timestamp = Instant.fromEpochSeconds(1001), - unreadSeq = 0, - ) - - private class Recorder : MessageLinkPrefetch { - val grants = mutableListOf() - override suspend fun prefetch(messages: List, wait: Duration, webLinks: Boolean) { - grants += webLinks - } - } - - private suspend fun load(open: ChatId?, type: ChatType, isMember: Boolean): List { - val messagingController = mockk(relaxed = true) - coEvery { messagingController.getMessages(chatId, any()) } returns Result.success(listOf(page)) - val metadata = mockk(relaxed = true) - coEvery { metadata.getChatType(chatId) } returns type - coEvery { metadata.isMember(chatId) } returns isMember - val state = ChatStateHolder().apply { update { it.copy(activeChat = open) } } - val recorder = Recorder() - MessagingDelegate( - chatController = mockk(relaxed = true), - messagingController = messagingController, - metadataDataSource = metadata, - messageDataSource = mockk(relaxed = true), - memberDataSource = mockk(relaxed = true), - notificationManager = mockk(relaxed = true), - userManager = mockk(relaxed = true), - stateHolder = state, - analytics = mockk(relaxed = true), - senderResolver = mockk(relaxed = true), - linkPrefetch = recorder, - ).loadMessages(chatId) - return recorder.grants - } - - @Test - fun `the open group's member gets web links`() = runTest { - assertEquals(listOf(true), load(open = chatId, type = ChatType.GROUP, isMember = true)) - } - - @Test - fun `the open group's non-member still prefetches, without web links`() = runTest { - assertEquals(listOf(false), load(open = chatId, type = ChatType.GROUP, isMember = false)) - } - - @Test - fun `an open DM gets web links`() = runTest { - assertEquals(listOf(true), load(open = chatId, type = ChatType.CONTACT_DM, isMember = false)) - } - - @Test - fun `a chat that is not open still prefetches, without web links`() = runTest { - assertEquals(listOf(false), load(open = null, type = ChatType.CONTACT_DM, isMember = true)) - } -} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/WebPreviewGateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/WebPreviewGateTest.kt deleted file mode 100644 index 5d9d87714..000000000 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/WebPreviewGateTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.flipcash.shared.chat - -import com.flipcash.app.persistence.sources.ChatMetadataDataSource -import com.flipcash.services.models.chat.ChatId -import com.flipcash.services.models.chat.ChatType -import com.flipcash.shared.chat.internal.ChatStateHolder -import com.flipcash.shared.chat.internal.WebPreviewGate -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class WebPreviewGateTest { - - private val chatId = ChatId(ByteArray(16) { it.toByte() }) - private val other = ChatId(ByteArray(16) { (it + 1).toByte() }) - - private val stateHolder = ChatStateHolder() - private val metadata = mockk() - private val gate = WebPreviewGate(stateHolder, metadata) - - private fun open(chat: ChatId?) = stateHolder.update { it.copy(activeChat = chat) } - - private fun stored(type: ChatType, isMember: Boolean = true) { - coEvery { metadata.getChatType(chatId) } returns type - coEvery { metadata.isMember(chatId) } returns isMember - } - - @Test - fun `a chat that is not the open one is refused`() = runTest { - stored(ChatType.CONTACT_DM) - open(other) - assertFalse(gate.allows(chatId)) - } - - @Test - fun `nothing open refuses every chat`() = runTest { - stored(ChatType.CONTACT_DM) - open(null) - assertFalse(gate.allows(chatId)) - } - - @Test - fun `the open group is refused to a non-member`() = runTest { - stored(ChatType.GROUP, isMember = false) - open(chatId) - assertFalse(gate.allows(chatId)) - } - - @Test - fun `the open group is allowed to a member`() = runTest { - stored(ChatType.GROUP, isMember = true) - open(chatId) - assertTrue(gate.allows(chatId)) - } - - @Test - fun `an open DM is allowed without reading membership`() = runTest { - stored(ChatType.CONTACT_DM, isMember = false) - open(chatId) - assertTrue(gate.allows(chatId)) - - stored(ChatType.TIP_DM, isMember = false) - assertTrue(gate.allows(chatId)) - coVerify(exactly = 0) { metadata.isMember(any()) } - } - - @Test - fun `an open chat of unknown type is refused`() = runTest { - stored(ChatType.UNKNOWN) - open(chatId) - assertFalse(gate.allows(chatId)) - } -} diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 169f53eb8..00e330d8d 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -88,17 +88,6 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } - @FeatureFlagMarker - data object WebLinkPreviews : FeatureFlag { - override val key: String = "web_link_previews" - override val default: Boolean = true - // Not launched: a launched flag is forced to its default and its toggle is hidden - // (InternalFeatureFlagController.get), which would leave no way to turn previews off. - override val launched: Boolean = false - override val visible: Boolean = true - override val persistLogOut: Boolean = false - } - companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -118,7 +107,6 @@ val FeatureFlag<*>.title: String FeatureFlag.BillTextures -> "Bill Textures" FeatureFlag.BackgroundReset -> "Background Reset" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" - FeatureFlag.WebLinkPreviews -> "Web Link Previews" } val FeatureFlag<*>.message: String @@ -129,5 +117,4 @@ val FeatureFlag<*>.message: String FeatureFlag.BillTextures -> "When enabled, you'll gain the ability to select textures for bills during currency creation" FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" - FeatureFlag.WebLinkPreviews -> "When enabled, a link to a web page in a chat shows the page's title, description and image, fetched from this device" } From d4b8c7bbc1188ae7aafd38701c7451739ee927de Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 23:21:00 -0400 Subject: [PATCH 09/10] fix(messenger): frost the link card's remove button and let the field animate it The remove button sits inside the card's top-end corner at a photo chip's remove-button size, on a haze blur of the card. The card no longer runs its own expand and shrink: that stacked on the field's height easing and left the field closing after the card had gone. The field now slides the card in and out the way it does the photo chips. Host and description share a baseline. --- .../screens/components/ChatBottomBar.kt | 43 ++++++------------- .../screens/components/ComposerLinkCard.kt | 43 +++++++++++-------- 2 files changed, 40 insertions(+), 46 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index c0c380d13..099465154 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -24,12 +24,6 @@ import com.flipcash.shared.chat.ui.media.composerImageReceiver import com.flipcash.shared.chat.ui.media.deleteLeftoverCaptures import androidx.compose.ui.platform.LocalContext import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeOut -import androidx.compose.animation.fadeIn -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -424,12 +418,7 @@ internal fun UserControlBottomBar( .composerSidePadding(composerInsets, hasComposer), ) { if (canType) { - // The last card shown, kept while it animates out after the preview goes. - val linkCardVisible = remember { MutableTransitionState(false) } - var linkCard by remember { mutableStateOf(state.composerLink) } - state.composerLink?.let { linkCard = it } - linkCardVisible.targetState = state.composerLink != null - val showsLinkCard = linkCardVisible.currentState || linkCardVisible.targetState + val linkCard = state.composerLink val chips = remember(state.composerPhotos, state.uploadStates) { state.composerPhotos.map { photo -> ComposerPhotoChip( @@ -549,7 +538,7 @@ internal fun UserControlBottomBar( } } } else null, - header = if (chips.isNotEmpty() || showsLinkCard) { + header = if (chips.isNotEmpty() || linkCard != null) { { Column { if (chips.isNotEmpty()) { @@ -561,22 +550,18 @@ internal fun UserControlBottomBar( newestChipModifier = Modifier.onGloballyPositioned { newestChipCoordinates = it }, ) } - // Under the photos, as on iOS, inset like the first chip. - AnimatedVisibility( - visibleState = linkCardVisible, - enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), - exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), - ) { - linkCard?.let { card -> - ComposerLinkCard( - state = card, - onRemove = { dispatch(ChatViewModel.Event.RemoveLinkPreview) }, - modifier = Modifier.padding( - start = CodeTheme.dimens.staticGrid.x3, - top = if (chips.isNotEmpty()) CodeTheme.dimens.staticGrid.x2 else 0.dp, - ), - ) - } + // Under the photos, as on iOS, inset like the first chip. Like the + // photos it has no animation of its own: the field eases its height + // whenever the header's changes, which slides the card in and out with it. + linkCard?.let { card -> + ComposerLinkCard( + state = card, + onRemove = { dispatch(ChatViewModel.Event.RemoveLinkPreview) }, + modifier = Modifier.padding( + start = CodeTheme.dimens.staticGrid.x3, + top = if (chips.isNotEmpty()) CodeTheme.dimens.staticGrid.x2 else 0.dp, + ), + ) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt index 3253bed53..eeb4d12ef 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn @@ -32,6 +31,12 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import com.flipcash.app.messenger.internal.link.ComposerLinkPreviewState import com.flipcash.core.R import com.getcode.theme.CodeTheme @@ -41,8 +46,8 @@ internal const val COMPOSER_LINK_CARD_TAG = "composer_link_card" /** * The draft link's preview as the recipient will see it (iOS `ComposerLinkChip`): the page's image, - * host, description and title, a spinner while the page loads or its image uploads, and a remove - * button hanging off the card's top-end corner. + * host, description and title, a spinner while the page loads or its image uploads, and a frosted + * remove button inside the card's top-end corner, like a photo chip's. */ @Composable internal fun ComposerLinkCard( @@ -57,15 +62,19 @@ internal fun ComposerLinkCard( is ComposerLinkPreviewState.Ready -> stringResource(R.string.description_chatLinkPreview, state.host, state.title) } - // The remove button hangs past the card's corner, so the card is inset by that much. + // The card is the haze's source, so the remove button frosts whatever sits under it. + val hazeState = rememberHazeState() Box(modifier.fillMaxWidth()) { Box(Modifier.testTag(COMPOSER_LINK_CARD_TAG)) { Row( modifier = Modifier - .padding(top = RemoveOverhang, end = RemoveOverhang) .widthIn(max = MaxCardWidth) - .background(Color.Black.copy(alpha = 0.25f), RoundedCornerShape(CardCorner)) + .clip(RoundedCornerShape(CardCorner)) + .hazeSource(hazeState) + .background(Color.Black.copy(alpha = 0.25f)) .padding(grid.x3) + // Keeps the text clear of the remove button in the top-end corner. + .padding(end = grid.x4) .semantics { contentDescription = label }, horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -79,11 +88,12 @@ internal fun ComposerLinkCard( is ComposerLinkPreviewState.Loading -> HostText(state.host) is ComposerLinkPreviewState.Ready -> { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - HostText(state.host) + // Different sizes, so they share a baseline rather than a top edge. + HostText(state.host, Modifier.alignByBaseline()) state.description?.let { Text( text = it, - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier.weight(1f, fill = false).alignByBaseline(), style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, maxLines = 1, @@ -102,15 +112,16 @@ internal fun ComposerLinkCard( } } } - RemoveButton(onRemove, Modifier.align(Alignment.TopEnd)) + RemoveButton(onRemove, hazeState, Modifier.align(Alignment.TopEnd)) } } } @Composable -private fun HostText(host: String) { +private fun HostText(host: String, modifier: Modifier = Modifier) { Text( text = host, + modifier = modifier, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textMain, maxLines = 1, @@ -162,15 +173,15 @@ private fun Spinner() { } @Composable -private fun RemoveButton(onRemove: () -> Unit, modifier: Modifier) { +private fun RemoveButton(onRemove: () -> Unit, hazeState: HazeState, modifier: Modifier) { val grid = CodeTheme.dimens.staticGrid - // Centred on the card's top-end corner, which sits RemoveOverhang in from this box's. + // Inside the card's top-end corner, the size of a photo chip's remove button. Box( modifier = modifier - .offset(x = RemoveSize / 2 - RemoveOverhang, y = RemoveOverhang - RemoveSize / 2) - .size(RemoveSize) + .padding(grid.x1) + .size(grid.x4) .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.55f)) + .hazeBlur(HazeInput.Sources(hazeState), HazeMaterials.ultraThin(containerColor = Color.Black)) .clickable(role = Role.Button, onClick = onRemove), contentAlignment = Alignment.Center, ) { @@ -187,5 +198,3 @@ private val ImageSize = 44.dp private val ImageCorner = 8.dp private val CardCorner = 14.dp private val MaxCardWidth = 280.dp -private val RemoveOverhang = 9.dp -private val RemoveSize = 18.dp From 9d946f56324124cf5f2ce6bc2545ef053527513e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 23:47:55 -0400 Subject: [PATCH 10/10] fix(messenger): offer a draft link preview only when the send can carry it The detector lowercases a scheme the keyboard capitalised, so "Https://kotlinlang.org" showed a composer card, but the server matches the preview url against the text byte for byte and the send dropped it. The composer now applies the same whole-link check before fetching. --- .../app/messenger/internal/link/ComposerLinkPreview.kt | 6 ++++++ .../messenger/internal/link/ComposerLinkPreviewTest.kt | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt index 3b2fc56b7..74cd25a9e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt @@ -116,8 +116,14 @@ internal class ComposerLinkPreview @Inject constructor( _state.value = null } + /** + * The link to preview, only when the send could carry it. The detector lowercases a scheme the + * keyboard capitalised ("Https://"), and the server matches the preview's url against the text + * byte for byte, so such a link would show a card that is then dropped at send. + */ private fun firstWebUrl(text: String): String? = (classifier.firstCard(detectUrls(text)) as? LinkCard.Web)?.url + ?.takeIf { LinkPreviewChecks.wholeLink(text, it) } private suspend fun prepare(scope: CoroutineScope, chatId: ChatId, url: String) { val host = hostOf(url) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt index 41947e8c4..052f237d7 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt @@ -106,6 +106,15 @@ class ComposerLinkPreviewTest { assertEquals("Title of https://example.com/a", card.title) } + @Test + fun `a capitalised scheme gets no preview, since the send could not carry it`() = + withPreview(MutableStateFlow("Https://example.com/a")) { preview -> + settle() + + assertNull(preview.state.value) + assertNull(preview.take("Https://example.com/a")) + } + @Test fun `removing keeps the preview off while the link stays`() { val draft = MutableStateFlow("look https://example.com/a")