Skip to content

Commit dc1c8e8

Browse files
committed
feat(messenger): draw message links as a card of the linked message
A link to /chat/{id}/m/{seq} now gets its own card instead of the group invite card. What the card shows depends on the viewer: - in the chat: the message, opened through the chat's own path so a private group's message is decrypted on device, and its reaction count - a public group they aren't in: GetMessage in FULL_OR_REDACTED; a redacted body is drawn blurred and offers Buy - a private group they aren't in: the group's record and Request - a DM they aren't in: that it is one A deleted or missing message, or a chat the server doesn't have, is Unresolved. Unknown membership is read as not a member, so the card never shows more than the server would hand a stranger. Card states follow Figma node 11396:118, values node 11493:201.
1 parent 0cc6ee6 commit dc1c8e8

15 files changed

Lines changed: 1157 additions & 20 deletions

File tree

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,25 @@
10411041
<!-- Web link card chip, shown to a viewer outside the group. -->
10421042
<string name="action_webLinkCard_showPreview">Show preview · %1$s</string>
10431043
<string name="label_linkCard_groupUnavailable">Group Unavailable</string>
1044+
<string name="label_messageLink_unresolvedTitle">Flipcash</string>
1045+
<string name="label_messageLink_message">Message</string>
1046+
<string name="label_messageLink_kindPublicGroup">Public group</string>
1047+
<string name="label_messageLink_kindPrivateGroup">Private group</string>
1048+
<string name="label_messageLink_kindDirect">Direct message</string>
1049+
<string name="label_messageLink_you">You</string>
1050+
<string name="label_messageLink_byline">%1$s · %2$s</string>
1051+
<string name="label_messageLink_sent">%1$s sent</string>
1052+
<string name="label_messageLink_tipped">%1$s tipped</string>
1053+
<string name="label_messageLink_attachment">Attachment</string>
1054+
<plurals name="label_messageLink_reactions">
1055+
<item quantity="one">%1$s reaction</item>
1056+
<item quantity="other">%1$s reactions</item>
1057+
</plurals>
1058+
<string name="label_messageLink_lockedGroup">Only members of %1$s can read this message.</string>
1059+
<string name="label_messageLink_lockedDirect">Only the two people in this chat can read it.</string>
1060+
<string name="label_messageLink_membersHostedBy">%1$s · Hosted by %2$s</string>
1061+
<string name="action_messageLink_open">Open</string>
1062+
<string name="action_messageLink_request">Request</string>
10441063

10451064
<!-- Chat message selection bar and its actions -->
10461065
<string name="action_copy">Copy</string>

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/GroupLinkLookup.kt‎

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.flipcash.app.messenger.internal.requiresStaff
77
import com.flipcash.app.tokens.TokenCoordinator
88
import com.flipcash.services.controllers.ChatController
99
import com.flipcash.services.models.chat.ChatId
10+
import com.flipcash.services.models.chat.ChatRules
1011
import com.flipcash.services.models.chat.ChatType
1112
import com.flipcash.services.models.chat.ViewMode
1213
import com.flipcash.shared.chat.FeaturedGroupsStore
@@ -48,28 +49,28 @@ internal class GroupLinkLookup @Inject constructor(
4849
// sitting empty through its own GetChat -- the same placeholder a featured group gets.
4950
featuredGroups.remember(listOf(chat))
5051

51-
val balance = chat.rules.balanceRequirement()
52-
val staffOnly = chat.rules.requiresStaff()
53-
val requirement = if (balance != null || staffOnly) {
54-
val token = balance?.mints?.firstOrNull()?.let { mint ->
55-
tokenCoordinator.getTokenMetadata(Mint(mint.bytes)).getOrNull()?.token
56-
}
57-
LinkCard.GroupInvite.Requirement(
58-
amount = balance?.amount?.formatted(),
59-
currencyName = token?.takeUnless { it.isReserve }?.brandedName(resources),
60-
staffOnly = staffOnly,
61-
)
62-
} else {
63-
null
64-
}
65-
6652
LinkCard.GroupInvite.State.Resolved(
6753
title = chat.title?.takeIf { it.isNotBlank() },
6854
picture = chat.picture,
6955
memberCount = chat.rosterSummary.memberCount,
70-
requirement = requirement,
56+
requirement = requirement(chat.rules),
7157
)
7258
}
7359

60+
/** What [rules] ask of a reader, worded as the invite card words it; null when nothing. */
61+
suspend fun requirement(rules: ChatRules?): LinkCard.GroupInvite.Requirement? {
62+
val balance = rules.balanceRequirement()
63+
val staffOnly = rules.requiresStaff()
64+
if (balance == null && !staffOnly) return null
65+
val token = balance?.mints?.firstOrNull()?.let { mint ->
66+
tokenCoordinator.getTokenMetadata(Mint(mint.bytes)).getOrNull()?.token
67+
}
68+
return LinkCard.GroupInvite.Requirement(
69+
amount = balance?.amount?.formatted(),
70+
currencyName = token?.takeUnless { it.isReserve }?.brandedName(resources),
71+
staffOnly = staffOnly,
72+
)
73+
}
74+
7475
class NotAGroup(chatId: ChatId) : IllegalStateException("chat $chatId is not a group")
7576
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,16 @@ internal class LinkCardClassifier @Inject constructor(
147147
},
148148
state = LinkCard.User.State.Loading,
149149
)
150+
// `/chat/{id}/m/{seq}`: the router has already matched the shape and told a group's
151+
// UUID from a DM's base64url id.
152+
is DeeplinkType.ChatMessage -> LinkCard.Message(
153+
url = target,
154+
start = link.start,
155+
end = link.end,
156+
chatId = type.chatId,
157+
messageId = type.messageId,
158+
isGroup = type.isGroup,
159+
)
150160
else -> null
151161
}
152162
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ internal object LinkCardModule {
3636
group: GroupLinkLookup,
3737
user: UserLinkLookup,
3838
web: WebLinkLookup,
39+
message: MessageLinkLookup,
3940
memory: LinkCardMemory,
4041
): LinkCardResolver =
4142
LinkCardResolver(
@@ -48,6 +49,7 @@ internal object LinkCardModule {
4849
group = { group(it) },
4950
user = { user(it) },
5051
web = web::invoke,
52+
message = message::invoke,
5153
memory = memory,
5254
)
5355
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ internal class LinkCardResolver(
5454
private val web: suspend (url: String) -> Result<LinkCard.Web.State> = {
5555
Result.failure(IllegalStateException("no web lookup"))
5656
},
57+
private val message: suspend (card: LinkCard.Message) -> Result<LinkCard.Message.State> = {
58+
Result.failure(IllegalStateException("no message lookup"))
59+
},
5760
private val memory: LinkCardMemory = LinkCardMemory(),
5861
) : LinkCardResolution {
5962

@@ -75,6 +78,7 @@ internal class LinkCardResolver(
7578
private val groupQueries = mutableMapOf<ChatId, Deferred<LinkCard.GroupInvite.State>>()
7679
private val userQueries = mutableMapOf<LinkCard.User.Identity, Deferred<LinkCard.User.State>>()
7780
private val webQueries = mutableMapOf<String, Deferred<Result<LinkCard.Web.State>>>()
81+
private val messageQueries = mutableMapOf<MessageKey, Deferred<LinkCard.Message.State>>()
7882

7983
/**
8084
* The answers that have landed, readable without the lock and without suspending — which is the
@@ -94,6 +98,12 @@ internal class LinkCardResolver(
9498
* query maps above are still per visit, so each visit asks once and writes over what it found.
9599
*/
96100
private val cashAnswers = ConcurrentHashMap<String, LinkCard.Cash.State.Resolved>()
101+
102+
/**
103+
* Message answers are held per visit, like cash: what a card may show turns on the viewer's
104+
* membership and the group's rules, both of which a join or a purchase moves.
105+
*/
106+
private val messageAnswers = ConcurrentHashMap<MessageKey, LinkCard.Message.State>()
97107
private val tokenAnswers get() = memory.tokens
98108
private val groupAnswers get() = memory.groups
99109
private val userAnswers get() = memory.users
@@ -122,6 +132,7 @@ internal class LinkCardResolver(
122132
is LinkCard.GroupInvite -> groupAnswers[card.chatId]?.let { card.copy(state = it) }
123133
is LinkCard.User -> userAnswers[card.identity]?.let { card.copy(state = it) }
124134
is LinkCard.Web -> WebLinks.cacheKey(card.url)?.let { memory.webs[it] }?.let { card.copy(state = it) }
135+
is LinkCard.Message -> messageAnswers[card.key]?.let { card.copy(state = it) }
125136
}
126137

127138
override suspend fun resolve(card: LinkCard): LinkCard = when (card) {
@@ -130,6 +141,7 @@ internal class LinkCardResolver(
130141
is LinkCard.GroupInvite -> card.copy(state = groupState(card.chatId))
131142
is LinkCard.User -> card.copy(state = userState(card.identity))
132143
is LinkCard.Web -> card.copy(state = webState(card.url))
144+
is LinkCard.Message -> card.copy(state = messageState(card))
133145
}
134146

135147
/**
@@ -288,6 +300,18 @@ internal class LinkCardResolver(
288300
}.getOrDefault(LinkCard.Web.State.None)
289301
}
290302

303+
private suspend fun messageState(card: LinkCard.Message): LinkCard.Message.State =
304+
memoized(messageQueries, card.key) {
305+
message(card).fold(
306+
onSuccess = { state -> state.also { messageAnswers[card.key] = it } },
307+
onFailure = { forget(messageQueries, card.key); LinkCard.Message.State.Unresolved },
308+
)
309+
}
310+
311+
private data class MessageKey(val chatId: ChatId, val messageId: Long)
312+
313+
private val LinkCard.Message.key get() = MessageKey(chatId, messageId)
314+
291315
private suspend fun <K, V> memoized(
292316
queries: MutableMap<K, Deferred<V>>,
293317
key: K,
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package com.flipcash.app.messenger.internal.link
2+
3+
import com.flipcash.services.controllers.ChatController
4+
import com.flipcash.services.controllers.ChatMessagingController
5+
import com.flipcash.services.controllers.ProfileController
6+
import com.flipcash.services.models.GetChatError
7+
import com.flipcash.services.models.GetMessageError
8+
import com.flipcash.services.models.UserProfile
9+
import com.flipcash.services.models.chat.BlobAccessContext
10+
import com.flipcash.services.models.chat.ChatId
11+
import com.flipcash.services.models.chat.ChatMessage
12+
import com.flipcash.services.models.chat.ChatMetadata
13+
import com.flipcash.services.models.chat.ChatType
14+
import com.flipcash.services.models.chat.MessageContent
15+
import com.flipcash.services.models.chat.ViewMode
16+
import com.flipcash.services.models.handle
17+
import com.flipcash.services.models.nameOrHandle
18+
import com.flipcash.services.user.UserManager
19+
import com.flipcash.shared.chat.ChatCoordinator
20+
import com.flipcash.shared.chat.models.LinkCard
21+
import com.getcode.opencode.model.core.ID
22+
import kotlinx.coroutines.flow.first
23+
import javax.inject.Inject
24+
25+
/**
26+
* Reads what a message link's card may show, which depends on the source chat and the viewer:
27+
*
28+
* - The viewer is in the chat: the message itself, opened on this device if it is end-to-end
29+
* encrypted, with its reaction count. ([LinkCard.Message.State.Resolved])
30+
* - A public group they aren't in: `GetMessage` in `FULL_OR_REDACTED`, which answers with the text
31+
* when the group's rules let them read and a placeholder when they don't.
32+
* ([LinkCard.Message.State.Gated])
33+
* - A private group they aren't in: the group's public record only, never the message.
34+
* ([LinkCard.Message.State.LockedGroup])
35+
* - A DM they aren't in: that it is one. ([LinkCard.Message.State.LockedDirect])
36+
*
37+
* A deleted message, a message or chat the server doesn't have, or one this device can't open
38+
* answers [LinkCard.Message.State.Unresolved]; a card never draws resolution failing as an error.
39+
* A request that didn't complete fails, so the resolver asks again on the next draw.
40+
*
41+
* Membership is read from the stored chat. A chat with no stored row, or one whose membership is
42+
* unknown, is treated as one the viewer is not in: what the card shows is then what the server
43+
* hands a stranger, so an unknown can never show more than it should.
44+
*/
45+
internal class MessageLinkLookup @Inject constructor(
46+
private val chats: ChatCoordinator,
47+
private val chatController: ChatController,
48+
private val messaging: ChatMessagingController,
49+
private val profiles: ProfileController,
50+
private val userManager: UserManager,
51+
private val groups: GroupLinkLookup,
52+
) {
53+
suspend operator fun invoke(card: LinkCard.Message): Result<LinkCard.Message.State> = runCatching {
54+
val stored = chats.observeMetadata(card.chatId).first()
55+
if (stored?.isMember == true) {
56+
member(stored.metadata, card.messageId)
57+
} else if (card.isGroup) {
58+
nonMemberGroup(card.chatId, card.messageId)
59+
} else {
60+
LinkCard.Message.State.LockedDirect
61+
}
62+
}.recoverCatching { error ->
63+
if (error.meansNothingThere()) LinkCard.Message.State.Unresolved else throw error
64+
}
65+
66+
private suspend fun member(chat: ChatMetadata, messageId: Long): LinkCard.Message.State {
67+
val message = chats.readMessage(chat.chatId, messageId).getOrThrow()
68+
val body = message.body() ?: return LinkCard.Message.State.Unresolved
69+
return LinkCard.Message.State.Resolved(
70+
source = source(chat),
71+
message = excerpt(message, body, chat),
72+
reactionCount = message.reactions?.reactions?.sumOf { it.count } ?: 0,
73+
)
74+
}
75+
76+
private suspend fun nonMemberGroup(chatId: ChatId, messageId: Long): LinkCard.Message.State {
77+
val chat = chatController.getChat(chatId, ViewMode.REDACTED).getOrThrow()
78+
if (chat.type != ChatType.GROUP) return LinkCard.Message.State.Unresolved
79+
if (chat.isPrivate) {
80+
return LinkCard.Message.State.LockedGroup(
81+
source = source(chat),
82+
memberCount = chat.rosterSummary.memberCount,
83+
host = chat.creator?.let { name(it) },
84+
)
85+
}
86+
val message = messaging.getMessage(chatId, messageId, ViewMode.FULL_OR_REDACTED).getOrThrow()
87+
val body = message.body() ?: return LinkCard.Message.State.Unresolved
88+
return LinkCard.Message.State.Gated(
89+
source = source(chat),
90+
message = excerpt(message, body, chat = null),
91+
requirement = groups.requirement(chat.rules),
92+
)
93+
}
94+
95+
private fun source(chat: ChatMetadata): LinkCard.Message.Source = when (chat.type) {
96+
ChatType.GROUP -> LinkCard.Message.Source(
97+
title = chat.title?.takeIf { it.isNotBlank() },
98+
kind = if (chat.isPrivate) LinkCard.Message.Kind.PrivateGroup else LinkCard.Message.Kind.PublicGroup,
99+
picture = chat.picture,
100+
pictureAccess = BlobAccessContext.ChatProfile(chat.chatId),
101+
)
102+
else -> {
103+
val peer = chat.members.firstOrNull { it.userId != userManager.accountId }
104+
LinkCard.Message.Source(
105+
title = peer?.userProfile?.displayLabel(),
106+
kind = LinkCard.Message.Kind.DirectMessage,
107+
picture = peer?.userProfile?.profilePicture,
108+
pictureAccess = BlobAccessContext.profile(peer?.userId),
109+
)
110+
}
111+
}
112+
113+
/**
114+
* The sender, named from the chat's stored roster when [chat] has one and from their public
115+
* profile otherwise. An unreadable name is left off rather than failing the card.
116+
*/
117+
private suspend fun excerpt(
118+
message: ChatMessage,
119+
body: LinkCard.Message.Body,
120+
chat: ChatMetadata?,
121+
): LinkCard.Message.Excerpt {
122+
val sender = message.senderId
123+
val isFromSelf = message.isFromSelf || (sender != null && sender == userManager.accountId)
124+
val author = sender?.let { id ->
125+
chat?.members?.firstOrNull { it.userId == id }?.userProfile?.displayLabel() ?: name(id)
126+
}
127+
return LinkCard.Message.Excerpt(
128+
author = author,
129+
isFromSelf = isFromSelf,
130+
sentAt = message.timestamp,
131+
body = if (message.redacted && body is LinkCard.Message.Body.Text) body.copy(redacted = true) else body,
132+
)
133+
}
134+
135+
private suspend fun name(userId: ID): String? =
136+
profiles.getProfileForUser(userId).getOrNull()?.displayLabel()
137+
138+
private fun UserProfile.displayLabel(): String? = nameOrHandle(displayName, handle)
139+
140+
/** The card's body for this message, or null when there is nothing to show: deleted or sealed. */
141+
private fun ChatMessage.body(): LinkCard.Message.Body? = content.singleOrNull()?.body()
142+
143+
private fun MessageContent.body(): LinkCard.Message.Body? = when (this) {
144+
is MessageContent.Text -> LinkCard.Message.Body.Text(text)
145+
is MessageContent.Reply -> content.singleOrNull()?.body()
146+
is MessageContent.Media -> caption?.let { LinkCard.Message.Body.Text(it.text) } ?: LinkCard.Message.Body.Other
147+
is MessageContent.Cash -> LinkCard.Message.Body.Cash(
148+
amount = amount.formatted(),
149+
tipped = action == MessageContent.Cash.Action.TIPPED,
150+
)
151+
is MessageContent.Widget, is MessageContent.System -> LinkCard.Message.Body.Other
152+
is MessageContent.Deleted, is MessageContent.Encrypted -> null
153+
}
154+
155+
private fun Throwable.meansNothingThere(): Boolean = when (this) {
156+
is GetMessageError.NotFound,
157+
is GetMessageError.Denied,
158+
is GetChatError.NotFound,
159+
is GetChatError.Denied -> true
160+
else -> false
161+
}
162+
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/MessageLinkPrefetcher.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ internal class MessageLinkPrefetcher(
7373
is LinkCard.Web -> if (!webLinks) null else WebLinks.cacheKey(card.url)
7474
?.takeUnless { it in memory.webs }
7575
?.let { key -> start(key) { web(card.url).onSuccess { memory.putWeb(key, it) } } }
76-
is LinkCard.Cash, is LinkCard.TokenInfo -> null
76+
// Held per visit, since who can read it changes with membership; resolved when drawn.
77+
is LinkCard.Cash, is LinkCard.TokenInfo, is LinkCard.Message -> null
7778
}
7879

7980
private fun start(key: Any, block: suspend () -> Unit): Deferred<Unit> =

0 commit comments

Comments
 (0)