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,