Skip to content
Merged
22 changes: 22 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,28 @@
<!-- 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>
<string name="label_messageLink_kindPrivateGroup">Private group</string>
<string name="label_messageLink_kindDirect">Direct message</string>
<string name="label_messageLink_you">You</string>
<string name="label_messageLink_someone">Someone</string>
<string name="label_messageLink_byline">%1$s · %2$s</string>
<string name="label_messageLink_sent">%1$s sent</string>
<string name="label_messageLink_tipped">%1$s tipped</string>
<string name="label_messageLink_sentCashLink">%1$s sent a cash link</string>
<string name="label_messageLink_attachment">Attachment</string>
<plurals name="label_messageLink_reactions">
<item quantity="one">%1$s reaction</item>
<item quantity="other">%1$s reactions</item>
</plurals>
<string name="label_messageLink_lockedGroup">Only members of %1$s can read this message.</string>
<string name="label_messageLink_deleted">This message was deleted.</string>
<string name="label_messageLink_settled">Settled on Solana</string>
<string name="label_messageLink_timeNow">now</string>
<string name="label_messageLink_timeMinutes">%1$dm</string>
<string name="label_messageLink_timeHours">%1$dh</string>
<string name="label_messageLink_lockedDirect">Only the two people in this chat can read it.</string>
<string name="label_messageLink_membersHostedBy">%1$s · Hosted by %2$s</string>
<string name="action_messageLink_request">Request</string>

<!-- Chat message selection bar and its actions -->
<string name="action_copy">Copy</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.flipcash.app.messenger.internal.requiresStaff
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatRules
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.ViewMode
import com.flipcash.shared.chat.FeaturedGroupsStore
Expand Down Expand Up @@ -48,28 +49,28 @@ internal class GroupLinkLookup @Inject constructor(
// sitting empty through its own GetChat -- the same placeholder a featured group gets.
featuredGroups.remember(listOf(chat))

val balance = chat.rules.balanceRequirement()
val staffOnly = chat.rules.requiresStaff()
val requirement = if (balance != null || staffOnly) {
val token = balance?.mints?.firstOrNull()?.let { mint ->
tokenCoordinator.getTokenMetadata(Mint(mint.bytes)).getOrNull()?.token
}
LinkCard.GroupInvite.Requirement(
amount = balance?.amount?.formatted(),
currencyName = token?.takeUnless { it.isReserve }?.brandedName(resources),
staffOnly = staffOnly,
)
} else {
null
}

LinkCard.GroupInvite.State.Resolved(
title = chat.title?.takeIf { it.isNotBlank() },
picture = chat.picture,
memberCount = chat.rosterSummary.memberCount,
requirement = requirement,
requirement = requirement(chat.rules),
)
}

/** What [rules] ask of a reader, worded as the invite card words it; null when nothing. */
suspend fun requirement(rules: ChatRules?): LinkCard.GroupInvite.Requirement? {
val balance = rules.balanceRequirement()
val staffOnly = rules.requiresStaff()
if (balance == null && !staffOnly) return null
val token = balance?.mints?.firstOrNull()?.let { mint ->
tokenCoordinator.getTokenMetadata(Mint(mint.bytes)).getOrNull()?.token
}
return LinkCard.GroupInvite.Requirement(
amount = balance?.amount?.formatted(),
currencyName = token?.takeUnless { it.isReserve }?.brandedName(resources),
staffOnly = staffOnly,
)
}

class NotAGroup(chatId: ChatId) : IllegalStateException("chat $chatId is not a group")
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ internal class LinkCardClassifier @Inject constructor(
return router.classify(DeepLink(target)) as? DeeplinkType.ChatMessage
}

/**
* The entropy of [text] when the whole message is one cash link, or null. A message link's card
* draws such a message as the payment it carries rather than as a raw URL; a link with any
* other text around it is not one.
*/
fun wholeCashLink(text: String): String? {
val url = text.trim().takeIf { it.isNotEmpty() && it.none(Char::isWhitespace) } ?: return null
val target = unwrapJumpTarget(url) ?: url
val host = runCatching { target.toUri().host }.getOrNull() ?: return null
if (host.lowercase() !in CARD_HOSTS) return null
return (router.classify(DeepLink(target)) as? DeeplinkType.CashLink)?.entropy?.takeIf { it.isNotBlank() }
}

private fun classify(link: DetectedUrl): LinkCard? {
val target = unwrapJumpTarget(link.url) ?: link.url
val host = runCatching { target.toUri().host }.getOrNull() ?: return null
Expand Down Expand Up @@ -147,6 +160,16 @@ internal class LinkCardClassifier @Inject constructor(
},
state = LinkCard.User.State.Loading,
)
// `/chat/{id}/m/{seq}`: the router has already matched the shape and told a group's
// UUID from a DM's base64url id.
is DeeplinkType.ChatMessage -> LinkCard.Message(
url = target,
start = link.start,
end = link.end,
chatId = type.chatId,
messageId = type.messageId,
isGroup = type.isGroup,
)
else -> null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ internal object LinkCardModule {
group: GroupLinkLookup,
user: UserLinkLookup,
web: WebLinkLookup,
message: MessageLinkLookup,
memory: LinkCardMemory,
classifier: LinkCardClassifier,
): LinkCardResolver =
LinkCardResolver(
// The resolver's own scope, ended by `ChatViewModel.onCleared`. A query outlives the
Expand All @@ -48,6 +50,8 @@ internal object LinkCardModule {
group = { group(it) },
user = { user(it) },
web = web::invoke,
message = message::invoke,
memory = memory,
wholeCashLink = classifier::wholeCashLink,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ internal class LinkCardResolver(
private val web: suspend (url: String) -> Result<LinkCard.Web.State> = {
Result.failure(IllegalStateException("no web lookup"))
},
private val message: suspend (card: LinkCard.Message) -> Result<LinkCard.Message.State> = {
Result.failure(IllegalStateException("no message lookup"))
},
private val memory: LinkCardMemory = LinkCardMemory(),
/** The entropy when a message's whole text is one cash link; see [LinkCardClassifier.wholeCashLink]. */
private val wholeCashLink: (text: String) -> String? = { null },
) : LinkCardResolution {

data class Snapshot(
Expand All @@ -75,6 +80,7 @@ internal class LinkCardResolver(
private val groupQueries = mutableMapOf<ChatId, Deferred<LinkCard.GroupInvite.State>>()
private val userQueries = mutableMapOf<LinkCard.User.Identity, Deferred<LinkCard.User.State>>()
private val webQueries = mutableMapOf<String, Deferred<Result<LinkCard.Web.State>>>()
private val messageQueries = mutableMapOf<MessageKey, Deferred<LinkCard.Message.State>>()

/**
* The answers that have landed, readable without the lock and without suspending — which is the
Expand All @@ -94,6 +100,12 @@ internal class LinkCardResolver(
* query maps above are still per visit, so each visit asks once and writes over what it found.
*/
private val cashAnswers = ConcurrentHashMap<String, LinkCard.Cash.State.Resolved>()

/**
* Message answers are held per visit, like cash: what a card may show turns on the viewer's
* membership and the group's rules, both of which a join or a purchase moves.
*/
private val messageAnswers = ConcurrentHashMap<MessageKey, LinkCard.Message.State>()
private val tokenAnswers get() = memory.tokens
private val groupAnswers get() = memory.groups
private val userAnswers get() = memory.users
Expand Down Expand Up @@ -122,6 +134,7 @@ internal class LinkCardResolver(
is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) }
is LinkCard.User -> userAnswers[card.identity]?.let { card.copy(state = it) }
is LinkCard.Web -> WebLinks.cacheKey(card.url)?.let { memory.webs[it] }?.let { card.copy(state = it) }
is LinkCard.Message -> messageAnswers[card.key]?.let { card.copy(state = it) }
}

override suspend fun resolve(card: LinkCard): LinkCard = when (card) {
Expand All @@ -130,6 +143,7 @@ internal class LinkCardResolver(
is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId))
is LinkCard.User -> card.copy(state = userState(card.identity))
is LinkCard.Web -> card.copy(state = webState(card.url))
is LinkCard.Message -> card.copy(state = messageState(card))
}

/**
Expand Down Expand Up @@ -288,6 +302,40 @@ internal class LinkCardResolver(
}.getOrDefault(LinkCard.Web.State.None)
}

private suspend fun messageState(card: LinkCard.Message): LinkCard.Message.State =
memoized(messageQueries, card.key) {
message(card).fold(
onSuccess = { state -> state.withCashLink().also { messageAnswers[card.key] = it } },
onFailure = { forget(messageQueries, card.key); LinkCard.Message.State.Unresolved },
)
}

/**
* Draws a linked message that is nothing but a cash link as that link's payment, through the
* same per-visit cash query a cash card in the transcript uses. A redacted body is a
* placeholder, so it is never read as a link; a link that does not resolve stays text.
*/
private suspend fun LinkCard.Message.State.withCashLink(): LinkCard.Message.State {
val excerpt = when (this) {
is LinkCard.Message.State.Resolved -> message
is LinkCard.Message.State.Gated -> message
else -> return this
}
val text = excerpt.body as? LinkCard.Message.Body.Text ?: return this
if (text.redacted) return this
val entropy = wholeCashLink(text.text) ?: return this
val cash = cashState(entropy) as? LinkCard.Cash.State.Resolved ?: return this
val body = excerpt.copy(body = LinkCard.Message.Body.CashLink(cash.amount, cash.claim))
return when (this) {
is LinkCard.Message.State.Resolved -> copy(message = body)
is LinkCard.Message.State.Gated -> copy(message = body)
}
}

private data class MessageKey(val chatId: ChatId, val messageId: Long)

private val LinkCard.Message.key get() = MessageKey(chatId, messageId)

private suspend fun <K, V> memoized(
queries: MutableMap<K, Deferred<V>>,
key: K,
Expand Down
Loading
Loading