diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 2c2d1f69f0..257d78da62 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
Private group
Direct message
@@ -1581,6 +1579,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/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts
index ed4cfa5916..1c21aec922 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 4403cde76f..64a25d93ec 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
@@ -18,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
@@ -132,8 +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.WebPreviewImages
+import com.flipcash.app.messenger.internal.link.ComposerLinkPreview
+import com.flipcash.app.messenger.internal.link.ComposerLinkPreviewState
import com.flipcash.shared.chat.reactions.ReactionError
import com.flipcash.shared.chat.reactions.ReactionPill
import com.flipcash.shared.chat.reactions.ReactionStrip
@@ -198,6 +200,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
@@ -291,10 +294,9 @@ 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(
openingAtMessage = openAtMessageId != null,
@@ -305,12 +307,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
@@ -415,6 +411,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(),
/**
@@ -951,6 +949,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
@@ -1413,6 +1415,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.
@@ -3262,6 +3265,28 @@ 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)
+ preview.state
+ .onEach { dispatchEvent(Event.ComposerLinkChanged(it)) }
+ .launchIn(viewModelScope)
+ eventFlow.filterIsInstance()
+ .onEach { preview.dismiss() }
+ .launchIn(viewModelScope)
+ }
+
// Send text message
eventFlow.filterIsInstance()
.onEach {
@@ -3274,6 +3299,9 @@ 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.
+ val linkPreview = composerLinkPreview?.take(textToSend)
+
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
@@ -3286,10 +3314,21 @@ internal class ChatViewModel @AssistedInject constructor(
viewModelScope.launch {
val sent = if (photos.isEmpty()) {
- chatCoordinator.sendMessage(chatId, textToSend, replyToMessageId)
- } else {
+ chatCoordinator.sendMessage(
+ chatId,
+ textToSend,
+ replyToMessageId,
+ linkPreview = linkPreview,
+ )
+ } 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 {
@@ -3660,7 +3699,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.
*
@@ -3846,6 +3884,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 })
}
@@ -4043,6 +4083,30 @@ 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,
+ ),
+ )
+}
+
/** See [ChatViewModel.State.messageLinkLanding]. */
internal enum class MessageLinkLanding {
/** The transcript is the viewer's to read, so the landing pages to the message and walks there. */
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 b7a3881e0e..0000000000
--- 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/ComposerLinkPreview.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt
new file mode 100644
index 0000000000..74cd25a9ef
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreview.kt
@@ -0,0 +1,240 @@
+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.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
+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.net.URI
+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.
+ * [state] is what the composer's card draws.
+ */
+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)
+ 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 {
+ 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()
+ 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)
+ dropCard()
+ if (preview == 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)
+ 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
+ }
+
+ /**
+ * 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)
+ _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 {
+ 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)
+ throw e
+ }
+ }
+
+ /** 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)
+ }
+ }
+ _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) {
+ 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() }
+
+ internal companion object {
+ val DEBOUNCE = 400.milliseconds
+ 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/link/LinkCardMemory.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardMemory.kt
index fb223a8552..04b222f4bf 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 2989f2b8b3..6fa65ff6df 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 0cf69f6d8b..c63f6d6817 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,
message: MessageLinkLookup,
memory: LinkCardMemory,
classifier: LinkCardClassifier,
@@ -49,7 +48,6 @@ internal object LinkCardModule {
tokenMetadata = { token(it) },
group = { group(it) },
user = { user(it) },
- web = web::invoke,
message = message::invoke,
memory = memory,
wholeCashLink = classifier::wholeCashLink,
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt
index 531d8fdd26..4beb8880ae 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 message: suspend (card: LinkCard.Message) -> Result = {
Result.failure(IllegalStateException("no message lookup"))
},
@@ -79,7 +76,6 @@ internal class LinkCardResolver(
private val tokenQueries = mutableMapOf>()
private val groupQueries = mutableMapOf>()
private val userQueries = mutableMapOf>()
- private val webQueries = mutableMapOf>>()
private val messageQueries = mutableMapOf>()
/**
@@ -133,7 +129,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
is LinkCard.Message -> messageAnswers[card.key]?.let { card.copy(state = it) }
}
@@ -142,7 +139,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
is LinkCard.Message -> card.copy(state = messageState(card))
}
@@ -280,28 +277,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 messageState(card: LinkCard.Message): LinkCard.Message.State =
memoized(messageQueries, card.key) {
message(card).fold(
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 489c31242b..0000000000
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.flipcash.app.messenger.internal.link
-
-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() }
- .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) } } }
- // Held per visit, since who can read it changes with membership; resolved when drawn.
- is LinkCard.Cash, is LinkCard.TokenInfo, is LinkCard.Message -> null
- }
-
- private fun start(key: Any, block: suspend () -> Unit): Deferred =
- 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 3d4583623b..48636978ef 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 3496b8328e..686f370b1d 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 2518d4fdb9..0000000000
--- 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 28fe24f338..0000000000
--- 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 82d44f5f20..63b03d7a40 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 0000000000..2e676767f7
--- /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 7244c68658..53566ab27c 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
@@ -94,7 +94,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()
@@ -539,8 +538,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/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt
index cb3dd2e2e7..0994651544 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
@@ -418,6 +418,7 @@ internal fun UserControlBottomBar(
.composerSidePadding(composerInsets, hasComposer),
) {
if (canType) {
+ val linkCard = state.composerLink
val chips = remember(state.composerPhotos, state.uploadStates) {
state.composerPhotos.map { photo ->
ComposerPhotoChip(
@@ -537,15 +538,32 @@ internal fun UserControlBottomBar(
}
}
} else null,
- header = if (chips.isNotEmpty()) {
+ header = if (chips.isNotEmpty() || linkCard != null) {
{
- 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. 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,
+ ),
+ )
+ }
+ }
}
} 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 0000000000..eeb4d12efc
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerLinkCard.kt
@@ -0,0 +1,200 @@
+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.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 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
+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 frosted
+ * remove button inside the card's top-end corner, like a photo chip's.
+ */
+@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 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
+ .widthIn(max = MaxCardWidth)
+ .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,
+ ) {
+ 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)) {
+ // 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).alignByBaseline(),
+ 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, hazeState, Modifier.align(Alignment.TopEnd))
+ }
+ }
+}
+
+@Composable
+private fun HostText(host: String, modifier: Modifier = Modifier) {
+ Text(
+ text = host,
+ modifier = modifier,
+ 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, hazeState: HazeState, modifier: Modifier) {
+ val grid = CodeTheme.dimens.staticGrid
+ // Inside the card's top-end corner, the size of a photo chip's remove button.
+ Box(
+ modifier = modifier
+ .padding(grid.x1)
+ .size(grid.x4)
+ .clip(CircleShape)
+ .hazeBlur(HazeInput.Sources(hazeState), HazeMaterials.ultraThin(containerColor = Color.Black))
+ .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
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 91b715ce0b..1384a3b645 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
new file mode 100644
index 0000000000..052f237d7d
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/ComposerLinkPreviewTest.kt
@@ -0,0 +1,174 @@
+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, 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 `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")
+ 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)
+ }
+ }
+}
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 486958a0a3..723725ab0b 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
@@ -638,105 +638,18 @@ 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))
}
private fun messageCard(text: String, redacted: Boolean = false) = LinkCard.Message(
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 6fc337017a..e86a8fa788 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 26d21d8fa3..0000000000
--- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcherTest.kt
+++ /dev/null
@@ -1,207 +0,0 @@
-package com.flipcash.app.messenger.internal.link
-
-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 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 f7a0bc4fab..f5cbd2a292 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 cd14242778..d782fa4aeb 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 deef75b6e0..0000000000
--- 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 2b4095d92c..e9ae0043ef 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/LinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt
index 9484ccba12..a941e198ea 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
@@ -251,12 +251,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/models/WebLinkPreview.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/WebLinkPreview.kt
deleted file mode 100644
index 2398828a6b..0000000000
--- 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 1505dc18af..921dffa322 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
@@ -234,18 +234,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 28f548e982..a93ae1c53e 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
@@ -394,7 +394,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.
*
@@ -406,7 +406,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
}
/**
@@ -515,14 +515,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,
@@ -718,9 +718,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 91e12884ef..6a0ec23c72 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,13 +1,10 @@
package com.flipcash.shared.chat.ui
-import android.net.Uri
+import com.flipcash.shared.chat.media.ChatPhoto
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
@@ -18,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
@@ -55,63 +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) }
- 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
@@ -131,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
@@ -192,7 +121,7 @@ private fun WebPreview(
hapticFeedbackEnabled = false,
),
) {
- PreviewImage(preview.imageUrl)
+ preview.photo?.let { SenderPreviewImage(it) }
Column(
modifier = Modifier.padding(CodeTheme.dimens.staticGrid.x2),
) {
@@ -224,80 +153,15 @@ 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, so the card closes up around the text rather than leaving a hole.
*/
@Composable
-private fun PreviewImage(imageUrl: String?) {
- 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) }
+private fun SenderPreviewImage(photo: ChatPhoto) {
+ var failed by remember(photo) { mutableStateOf(false) }
if (failed) return
AsyncImage(
- model = imageUrl,
- imageLoader = loader,
+ model = photo,
contentDescription = null,
contentScale = ContentScale.Crop,
onError = { failed = true },
@@ -308,25 +172,3 @@ private fun PreviewImage(imageUrl: String?) {
.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 1f1e6d1c36..828666245c 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 4b726ab478..0000000000
--- 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 53489bee27..19edae3dce 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 c32ea1f92a..bbb452ff9e 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,48 +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,
- ) {
+ 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)
+ CompositionLocalProvider(LocalUriHandler provides uriHandler) {
+ Card(seed)
}
}
}
}
@Composable
- private fun Card(onLongClick: (() -> Unit)?) {
- val state = rememberWebLinkCard(card())
- 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 {
@@ -109,206 +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 `an empty answer draws nothing`() {
- setCard(resolving(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)
+ 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()
- }
-
- @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)
+ fun `a card with an empty answer draws nothing`() {
+ setCard(card(LinkCard.Web.State.None))
- 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/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
index 5d4b030913..9dba25e713 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
@@ -500,11 +501,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/MessageLinkPrefetch.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageLinkPrefetch.kt
deleted file mode 100644
index 550bd6ab0f..0000000000
--- 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/PendingMutation.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PendingMutation.kt
index fcaa3eadd0..f8f6444934 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 2bbbb0ec8a..fc277f5f8a 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/WebPreviewGate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/WebPreviewGate.kt
deleted file mode 100644
index 68ac813c8b..0000000000
--- 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 5859f26b7d..acecaaacaa 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 2ee87c8b94..d605123b31 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 1609bbff4c..e6bab14787 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/MediaSendDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MediaSendDelegate.kt
index f643f84f7c..067feb5510 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 12ce09ab61..cef5c5c30b 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
@@ -46,11 +51,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 java.nio.ByteBuffer
@@ -102,14 +105,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.
*
@@ -351,8 +352,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
@@ -370,7 +369,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
@@ -440,6 +438,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"))
@@ -448,19 +447,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 {
@@ -481,11 +496,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)
@@ -533,7 +549,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)
@@ -789,6 +811,23 @@ class MessagingDelegate @Inject constructor(
}
}
+/**
+ * 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() }
+}
+
/** A message id as a paging token, as [ChatMessageRemoteMediator] writes it. */
private fun Long.toPagingToken(): List =
ByteBuffer.allocate(Long.SIZE_BYTES).putLong(this).array().toList()
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
new file mode 100644
index 0000000000..4f5115d71c
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/link/LinkPreviewChecks.kt
@@ -0,0 +1,118 @@
+package com.flipcash.shared.chat.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.
+ */
+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/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 0000000000..65025f021e
--- /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 0000000000..abf209aa1e
--- /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 0000000000..995dad5b88
--- /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/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 b6e2021fd8..a834348ee3 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
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 c9cac2d949..0000000000
--- 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 5d9d877146..0000000000
--- 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/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
new file mode 100644
index 0000000000..71118c1b4f
--- /dev/null
+++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/link/LinkPreviewVectorTest.kt
@@ -0,0 +1,65 @@
+package com.flipcash.shared.chat.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/shared/chat/src/test/resources/link_preview_send.json b/apps/flipcash/shared/chat/src/test/resources/link_preview_send.json
new file mode 100644
index 0000000000..216baa03c9
--- /dev/null
+++ b/apps/flipcash/shared/chat/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"
+ }
+ ]
+}
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 169f53eb8f..00e330d8dc 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"
}
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 c67b89a811..ed8b41cfc1 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 a60e8df851..efae576f34 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 e738bc4511..9dfe768b2c 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 7cf4b6ccb6..7df50daf5a 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 7f0eea1712..beff6f2595 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 b75d57d178..d03e2af624 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 cc00394f93..2c4f3f3dfa 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 0000000000..186ee5f358
--- /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 659c508f42..b96c84654a 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,