Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f5e7dbf
test(messenger): sync link fixtures and retire aheadOfFixture
bmc08gt Oct 8, 2026
7e5a47a
fix(chat): drop a closing format marker from a wrapped link
bmc08gt Oct 8, 2026
c870338
chore(libs): delete unused opengraph module
bmc08gt Oct 8, 2026
4c35a50
feat(messenger): LinkCard.Web and web host rules
bmc08gt Oct 8, 2026
731440d
feat(messenger): classify outside https links as web cards
bmc08gt Oct 8, 2026
08e39d2
feat(messenger): web page head scanner held to link_metadata.json
bmc08gt Oct 8, 2026
559ac7f
fix(messenger): give a percent-escaped host no web card
bmc08gt Oct 8, 2026
8b09ebc
feat(messenger): web link lookup with public-only dns and fetch caps
bmc08gt Oct 8, 2026
abe0e2a
fix(messenger): keep link preview fetches off the system proxy
bmc08gt Oct 8, 2026
fd77e1c
feat(messenger): give a non-443 port no web card
bmc08gt Oct 8, 2026
3a783fb
feat(messenger): refuse dotted, numeric and NAT64 private hosts
bmc08gt Oct 8, 2026
6a3b1df
feat(messenger): refuse special-use address ranges
bmc08gt Oct 8, 2026
3a8ef16
fix(messenger): check a redirect's escaped host after resolving it
bmc08gt Oct 8, 2026
180ca90
fix(messenger): reject conflicting or encoded repeated headers
bmc08gt Oct 8, 2026
f4b24a3
feat(messenger): bound a link preview lookup by one deadline
bmc08gt Oct 8, 2026
4f95ac9
feat(messenger): resolve and persist web link cards
bmc08gt Oct 8, 2026
92b3962
feat(chat): prefetch web links only for the open chat's members
bmc08gt Oct 8, 2026
53d11f1
fix(messenger): expire remembered web cards by their ttl
bmc08gt Oct 8, 2026
b269a29
feat(messenger): fetch link preview images under the page rules
bmc08gt Oct 8, 2026
868d41c
feat(chat): render web link cards under message text
bmc08gt Oct 8, 2026
12c6282
feat(chat): show no web card while web link previews are off
bmc08gt Oct 8, 2026
0f98b2e
fix(messenger): drop a page image the fetch rules refuse
bmc08gt Oct 8, 2026
d54a2a5
feat(chat): draw the web card as a panel with a filled chip
bmc08gt Oct 8, 2026
f07b81c
feat(chat): draw a link-only message's web card without a bubble
bmc08gt Oct 8, 2026
ca28a28
fix(chat): let a cash card's claim pill act on the first tap
bmc08gt Oct 8, 2026
d396b64
test(messenger): take the D14 address ranges from the shared fixture
bmc08gt Oct 8, 2026
0754b63
fix(messenger): end a web card's loading when its lookup fails
bmc08gt Oct 8, 2026
28a6f5c
feat(chat): hold a link-only message's place with a card placeholder …
bmc08gt Oct 8, 2026
2e2cd9f
feat(chat): outline a bare web card so its edge shows over a dark image
bmc08gt Oct 9, 2026
1f471ae
test(chat): guard that a cached preview image draws on the card's fir…
bmc08gt Oct 9, 2026
1da29f2
feat(messenger): keep link preview images on disk with their rows
bmc08gt Oct 9, 2026
2fefebe
feat(messenger): load saved link previews at app start and before a c…
bmc08gt Oct 9, 2026
7bacd1f
chore(messenger): sync link_metadata fixture and raise the page body …
bmc08gt Oct 9, 2026
5ad9767
feat(messenger): stop reading a page once its head has ended
bmc08gt Oct 9, 2026
7abc92f
feat(messenger): fall back to the home page when a link has no card o…
bmc08gt Oct 9, 2026
475e4b7
test(messenger): sync link_metadata fixture from orchestrator #44 and…
bmc08gt Oct 9, 2026
9fdbff8
feat(messenger): give flipcash.com pages a web card unless the link c…
bmc08gt Oct 9, 2026
6455b91
chore(chat-ui): drop the pending-review notes on the preview chip copy
bmc08gt Oct 9, 2026
1f226ad
test(chat-ui): pass the invite's resolved state to OpenGroup
bmc08gt Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.getcode.opencode.repositories.EventRepository
import com.getcode.utils.trace
import dev.bmcreations.phantom.connect.PhantomSdk
import dagger.Lazy
import com.flipcash.app.messenger.LinkPreviewStartup
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -56,6 +57,13 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.
@Inject
lateinit var chatPhotoFetcher: Lazy<ChatPhotoFetcher.Factory>

/**
* Built at launch so the saved link previews are reading from the database before a chat opens,
* not when the first one asks. Its init starts the read.
*/
@Inject
lateinit var linkPreviewStartup: Lazy<LinkPreviewStartup>

@Inject
lateinit var workerFactory: Lazy<HiltWorkerFactory>

Expand All @@ -80,6 +88,7 @@ class FlipcashApp : Application(), Configuration.Provider, SingletonImageLoader.
authManager.get().init()
eventRepository.get()
preferredCurrencyController.get()
linkPreviewStartup.get()
}

// Track the foreground Activity so the tip-code share preview can render offscreen.
Expand Down
2 changes: 2 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,8 @@
<!-- Group invite link card. The untitled name is provisional, pending copy. -->
<string name="label_linkCard_untitledGroup">Group Chat</string>
<string name="action_linkCard_view">View</string>
<!-- Web link card chip, shown to a viewer outside the group. -->
<string name="action_webLinkCard_showPreview">Show preview · %1$s</string>
<string name="label_linkCard_groupUnavailable">Group Unavailable</string>

<!-- Chat message selection bar and its actions -->
Expand Down
8 changes: 8 additions & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,17 @@ dependencies {
implementation(libs.bundles.haze)
// Stored link previews (PersistedLinkCardMemory) are JSON.
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)
// A TLS MockWebServer, to see headers as OkHttp really sends and receives them (WebLinkLookupTest).
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.okhttp.tls)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.paging.testing)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.flipcash.app.messenger

import com.flipcash.app.messenger.internal.link.LinkCardMemory
import javax.inject.Inject
import javax.inject.Singleton

/**
* Starts reading the saved link previews at launch.
*
* The store reads its database when it is built, and Hilt builds it on first injection, which
* without this is when a chat first opens. Injecting this from the application builds it at
* launch, so the rows are in memory before any chat is.
*/
@Singleton
class LinkPreviewStartup @Inject internal constructor(
private val memory: LinkCardMemory,
) {
/** Whether the saved previews have been read. */
val isLoaded: Boolean get() = memory.isLoaded
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import androidx.paging.PagingData
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
Expand Down Expand Up @@ -45,6 +48,7 @@ import com.flipcash.app.messenger.internal.payment.StartChattingPayer
import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.app.messenger.internal.link.ClaimReplyTargets
import com.flipcash.app.messenger.internal.link.LinkCardClassifier
import com.flipcash.app.messenger.internal.link.LinkCardMemory
import com.flipcash.app.messenger.internal.link.LinkCardResolver
import com.flipcash.app.messenger.internal.mention.activeMentionToken
import com.flipcash.app.messenger.internal.mention.insertMention
Expand Down Expand Up @@ -118,6 +122,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.shared.chat.reactions.ReactionError
import com.flipcash.shared.chat.reactions.ReactionPill
import com.flipcash.shared.chat.reactions.ReactionStrip
Expand Down Expand Up @@ -203,9 +209,11 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withContext

data class TypingConstraints(
Expand Down Expand Up @@ -250,12 +258,41 @@ internal class ChatViewModel @AssistedInject constructor(
private val rosterSearch: RosterSearchSource,
private val featuredGroups: FeaturedGroupsStore,
private val dispatchers: DispatcherProvider,
// Last and defaulted so the transcript tests, which draw no pictures, need not supply one.
@WebPreviewImages val webPreviewImageLoader: ImageLoader? = null,
linkCardMemory: LinkCardMemory? = null,
featureFlags: FeatureFlagController = NoOpFeatureFlagController,
) : BaseViewModel<ChatViewModel.State, ChatViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
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<Boolean> = 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
* arrives shows its placeholder and then grows into the answer. So the first draw waits for
* them, for at most [PREVIEWS_WAIT], and then goes ahead with whatever has arrived. A store
* already loaded costs nothing, which is every chat after the first.
*/
private val _previewsReady = MutableStateFlow(linkCardMemory == null || linkCardMemory.isLoaded)
val previewsReady: StateFlow<Boolean> = _previewsReady.asStateFlow()

init {
if (!_previewsReady.value && linkCardMemory != null) {
viewModelScope.launch {
withTimeoutOrNull(PREVIEWS_WAIT) { linkCardMemory.awaitLoaded() }
_previewsReady.value = true
}
}
}

/**
* A photo in the composer: its id in [ChatMediaUploads] and where it came from, for the
* thumbnail. A camera shot staged at the shutter carries the frame taken then as [preview],
Expand Down Expand Up @@ -2991,6 +3028,10 @@ internal class ChatViewModel @AssistedInject constructor(
}

companion object {
/** 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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import com.flipcash.app.router.Router
import com.flipcash.shared.chat.models.LinkCard
import com.flipcash.shared.chat.ui.DetectedUrl
import dev.theolm.rinku.DeepLink
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import javax.inject.Inject

/**
Expand Down Expand Up @@ -48,7 +50,34 @@ internal class LinkCardClassifier @Inject constructor(
* from: the bubble draws the card in place of that text, and only the detection pass knows
* where it sat.
*/
fun firstCard(links: List<DetectedUrl>): LinkCard? = links.firstNotNullOfOrNull { classify(it) }
fun firstCard(links: List<DetectedUrl>): LinkCard? =
links.firstNotNullOfOrNull { classify(it) } ?: links.firstNotNullOfOrNull { web(it) }

/**
* An outside https link, or a page of the website (parity decision P25). Every other card host
* never falls through to here, whatever its path, and that includes a jump wrapper around an
* outside target.
*/
private fun web(link: DetectedUrl): LinkCard.Web? {
if (WebLinks.hasEscapedHost(link.url)) return null
val url = link.url.toHttpUrlOrNull() ?: return null
// An explicit port other than 443 gets no card (parity decision D12).
if (url.scheme != "https" || url.port != 443) return null
if (url.host in CARD_HOSTS && !url.isWebsitePage()) return null
if (!WebLinks.isEligibleHost(url.host)) return null
return LinkCard.Web(url = link.url, start = link.start, end = link.end)
}

/**
* A link on the website's hosts that is not a Flipcash card (P25). The router matches
* `/login`, `/c`, `/cash` and `/verify` on any host, so `flipcash.com/login/e=<seed>` would
* otherwise be fetched with the seed in its path; those segments never fall through, and
* neither does a link with a fragment, where entropy travels.
*/
private fun HttpUrl.isWebsitePage(): Boolean =
host in WEBSITE_HOSTS &&
fragment == null &&
pathSegments.first().lowercase() !in SECRET_SEGMENTS

private fun classify(link: DetectedUrl): LinkCard? {
val target = unwrapJumpTarget(link.url) ?: link.url
Expand Down Expand Up @@ -144,6 +173,12 @@ internal class LinkCardClassifier @Inject constructor(
private const val GROUP_INVITE_SEGMENTS = 2

private const val JUMP_HOST = "jump.flipcash.com"

/** Hosts whose non-card links are website pages (P25). */
private val WEBSITE_HOSTS = setOf("flipcash.com", "www.flipcash.com")

/** First path segments that carry a seed, entropy or a verification code (P25). */
private val SECRET_SEGMENTS = setOf("login", "verify", "c", "cash")
private const val JUMP_SOURCE_PARAM = "source="

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,52 @@ import java.util.concurrent.ConcurrentHashMap
* The maps are read directly -- [LinkCardResolver.peek] runs during composition and must not
* suspend -- and written only through the `put` functions, so a subclass sees every answer.
*/
internal open class LinkCardMemory {
internal open class LinkCardMemory(
protected val clock: () -> Long = { System.currentTimeMillis() },
) {
val tokens: Map<Mint, LinkCard.TokenInfo.State.Resolved> get() = _tokens
val groups: Map<ChatId, LinkCard.GroupInvite.State.Resolved> get() = _groups
val users: Map<LinkCard.User.Identity, LinkCard.User.State.Resolved> 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<String, LinkCard.Web.State> = FreshWebs()

private class Stored(val state: LinkCard.Web.State, val at: Long)

private val _webEntries = ConcurrentHashMap<String, Stored>()

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<String, LinkCard.Web.State>() {
override val entries: Set<Map.Entry<String, LinkCard.Web.State>>
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<String, LinkCard.Web.State> = _webEntries.mapValues { it.value.state }

protected fun clearWebs() = _webEntries.clear()

protected val _tokens = ConcurrentHashMap<Mint, LinkCard.TokenInfo.State.Resolved>()
protected val _groups = ConcurrentHashMap<ChatId, LinkCard.GroupInvite.State.Resolved>()
protected val _users = ConcurrentHashMap<LinkCard.User.Identity, LinkCard.User.State.Resolved>()
Expand All @@ -39,6 +80,10 @@ 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
}
Expand All @@ -62,4 +107,7 @@ internal open class LinkCardMemory {
* every stored answer. Memory alone has nothing to read.
*/
open suspend fun awaitLoaded() = Unit

/** Whether [awaitLoaded] would return at once. Memory alone has nothing to read, so it always would. */
open val isLoaded: Boolean get() = true
}
Loading
Loading