Skip to content

Commit 3590d47

Browse files
authored
feat(chat): open @handle mentions in message bubbles (#1609)
* feat(chat): open @handle mentions in message bubbles Ports code-ios-app#882. A `@handle` in a text or reply bubble is drawn as a rounded pill and opens whoever it names. Detection runs once per message in the mapper, beside the links, with iOS's pattern. A handle inside a link (`x.com/@jeff`) stays the link's. Mentions live on `ContentBubble.mentions`, outside card classification, and `splitAroundLinkCard` rebases each into its own row. A tap looks the handle up through `LinkCardResolver.lookUpUser`, which shares the person card's lookup and memory, so a handle already resolved opens without a round trip. Where it lands: - the viewer's own handle: the You tab, as a person card to yourself does - the open DM's counterpart: their profile as the title opens it, with Mute and without Message - anyone else: their profile with Message and Send Cash and no Mute (`ProfileOrigin.Mention`) - unclaimed: "No Such Account"; any other failure: "Couldn't Open Profile" The pill uses iOS's values: white at 14%, 6dp radius, 6dp padding and a 3dp gap, drawn per line when the handle wraps. The room either side comes from inline placeholders, since Compose has no per-character kerning. * fix(chat): size text bubbles to their widest line and unstyle mentions A Compose Text that wraps reports the full width it was offered, so a wrapped message stretched its bubble to the maximum width. A mention pill wrapping onto its own line left most of the bubble empty. The body now measures itself once at the offered width and takes the width of its widest line, as SwiftUI text does on iOS. A clickable span with no style is drawn as a web link, so mentions came out purple and underlined over their pills. They now take the body's colour with no underline.
1 parent 9382ce1 commit 3590d47

18 files changed

Lines changed: 870 additions & 23 deletions

File tree

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ sealed interface ChatStep : FlowStep, Parcelable {
7575

7676
@Parcelize
7777
@Serializable
78-
data class Profile(val contact: ChatParticipant): ChatStep
78+
data class Profile(
79+
val contact: ChatParticipant,
80+
val origin: ProfileOrigin = ProfileOrigin.Chat,
81+
): ChatStep
7982

8083
/**
8184
* The group's own profile — the counterpart of [Profile] for a chat that has no counterparty.
@@ -138,3 +141,20 @@ sealed interface ChatStep : FlowStep, Parcelable {
138141
@Serializable
139142
data class Reactors(val chatId: ChatId, val messageId: Long) : ChatStep, Sheet, HalfSheet
140143
}
144+
145+
/** What opened a [ChatStep.Profile], which decides the actions it offers. */
146+
@Serializable
147+
enum class ProfileOrigin {
148+
/**
149+
* The chat's own title, or a member's picture or reactor row. The chat decides: a tip DM's
150+
* profile is the other person in it, so it offers Mute and not Message.
151+
*/
152+
Chat,
153+
154+
/**
155+
* A tapped `@handle` naming someone other than the DM's counterpart. That person is not who
156+
* the chat is with, so Mute would act on the wrong chat and Message and Send Cash have
157+
* somewhere new to go. iOS's `UserProfileOrigin.mention`.
158+
*/
159+
Mention,
160+
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.flipcash.app.core.chat.ChatIdentifier
1919
import com.flipcash.app.core.chat.ChatParticipant
2020
import com.flipcash.app.core.chat.ChatSendResult
2121
import com.flipcash.app.core.chat.ChatStep
22+
import com.flipcash.app.core.chat.ProfileOrigin
2223
import com.flipcash.app.core.chat.GroupInviteResult
2324
import com.flipcash.app.core.extensions.openAsSheet
2425
import com.flipcash.app.messenger.internal.ChatSubject
@@ -110,7 +111,7 @@ private fun chatEntryProvider(
110111
}
111112

112113
annotatedEntry<ChatStep.Profile> { step ->
113-
FlowChatProfileScreen(step.contact)
114+
FlowChatProfileScreen(step.contact, step.origin)
114115
}
115116
annotatedEntry<ChatStep.GroupProfile> {
116117
FlowGroupProfileScreen()
@@ -310,15 +311,15 @@ private fun FlowGroupInviteSheet() {
310311
}
311312

312313
@Composable
313-
private fun FlowChatProfileScreen(participant: ChatParticipant) {
314+
private fun FlowChatProfileScreen(participant: ChatParticipant, origin: ProfileOrigin) {
314315
val viewModel = flowSharedViewModel<ChatProfileViewModel>()
315316
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()
316317

317318
LaunchedEffect(viewModel, participant) {
318319
viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant))
319320
}
320321

321-
ChatProfileScreen(viewModel, flowSharedViewModel<ChatViewModel>())
322+
ChatProfileScreen(viewModel, flowSharedViewModel<ChatViewModel>(), origin)
322323

323324
LaunchedEffect(viewModel) {
324325
viewModel.eventFlow

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import com.flipcash.shared.chat.reactions.ReactionStripComposer
101101
import com.flipcash.shared.chat.reactions.SelfReaction
102102
import com.flipcash.shared.chat.readOnly
103103
import com.flipcash.shared.chat.resolveCapabilities
104+
import com.flipcash.shared.chat.ui.detectMentions
104105
import com.flipcash.shared.chat.ui.detectUrls
105106
import com.flipcash.shared.chat.ui.linkableText
106107
import com.flipcash.shared.chat.withinWindows
@@ -581,6 +582,15 @@ internal class ChatViewModel @Inject constructor(
581582
data object NavigateToInitPayment : Event
582583
data object PresentDepositOptions : Event
583584
data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event
585+
586+
/** The reader tapped a mention of [username] in the transcript. */
587+
data class MentionTapped(val username: String) : Event
588+
589+
/**
590+
* Where a tapped mention resolved to, for the screen to open. Only the destinations that
591+
* open something: an unclaimed handle or a failed lookup is a dialog raised here.
592+
*/
593+
data class OpenMention(val destination: MentionDestination) : Event
584594
data object OnConfirmRequested : Event
585595

586596
/** Confirm the DM-opening fee. The amount comes from the fee, not from the keypad. */
@@ -850,8 +860,13 @@ internal class ChatViewModel @Inject constructor(
850860
// which meant a chat painted nothing until every link in the first window had
851861
// been round-tripped, and that every link in every mapped message was queried
852862
// whether or not the reader ever scrolled to it.
853-
val linkCard = enriched.linkableText()
854-
?.let { text -> linkCardClassifier.firstCard(detectUrls(text)) }
863+
val linkableText = enriched.linkableText()
864+
val links = linkableText?.let(::detectUrls).orEmpty()
865+
val linkCard = linkCardClassifier.firstCard(links)
866+
867+
// Detected over the whole text, beside the links, so a handle inside a link
868+
// stays the link's. Nothing is looked up until one is tapped.
869+
val mentions = linkableText?.let { detectMentions(it, links) }.orEmpty()
855870

856871
// Noted while the voucher and the message it came on are in the same hand;
857872
// see [ClaimReplyTargets]. Skipped for the reader's own messages, which is what
@@ -917,6 +932,7 @@ internal class ChatViewModel @Inject constructor(
917932
// after the first page does.
918933
senderId = message.senderId?.takeIf { !message.isFromSelf },
919934
linkCard = linkCard,
935+
mentions = mentions,
920936
// `splitAroundLinkCard` keeps these pills on the last row only.
921937
reactionPills = storedReactions.pills,
922938
selfReactions = storedReactions.selfReactions,
@@ -1152,6 +1168,7 @@ internal class ChatViewModel @Inject constructor(
11521168
initChatHandlers()
11531169
initLinkCardFreshness()
11541170
initClaimReplies()
1171+
initMentionHandlers()
11551172
initDraftHandlers()
11561173
initGroupAnalytics()
11571174

@@ -1248,6 +1265,33 @@ internal class ChatViewModel @Inject constructor(
12481265
.launchIn(viewModelScope)
12491266
}
12501267

1268+
/**
1269+
* Opens where a tapped `@handle` leads. Looked up through [linkCardResolver], so a handle a
1270+
* person card or an earlier tap already resolved opens without a round trip.
1271+
*
1272+
* An unclaimed handle and a failed lookup get different dialogs: one is an answer, the other
1273+
* is worth trying again.
1274+
*/
1275+
private fun initMentionHandlers() {
1276+
eventFlow.filterIsInstance<Event.MentionTapped>()
1277+
.onEach { event ->
1278+
val lookup = linkCardResolver.lookUpUser(LinkCard.User.Identity.ByUsername(event.username))
1279+
val counterpart = (stateFlow.value.participant as? ChatParticipant.TipUser)?.userId
1280+
when (val destination = mentionDestination(event.username, lookup, counterpart)) {
1281+
is MentionDestination.NoSuchAccount -> BottomBarManager.showInfo(
1282+
title = resources.getString(R.string.error_title_usernameNotFound),
1283+
message = resources.getString(R.string.error_description_usernameNotFound, destination.username),
1284+
)
1285+
MentionDestination.LookupFailed -> BottomBarManager.showError(
1286+
title = resources.getString(R.string.error_title_profileUnavailable),
1287+
message = resources.getString(R.string.error_description_profileUnavailable),
1288+
)
1289+
else -> dispatchEvent(Event.OpenMention(destination))
1290+
}
1291+
}
1292+
.launchIn(viewModelScope)
1293+
}
1294+
12511295
/**
12521296
* Replies to the voucher with a thank-you, once a link tapped here has actually been collected.
12531297
*
@@ -2636,6 +2680,8 @@ internal class ChatViewModel @Inject constructor(
26362680
Event.NavigateToInitPayment -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
26372681
is Event.PresentDepositOptions -> { state -> state }
26382682
is Event.OpenScreen -> { state -> state }
2683+
is Event.MentionTapped -> { state -> state }
2684+
is Event.OpenMention -> { state -> state }
26392685
is Event.OnConfirmRequested -> { state -> state }
26402686
is Event.OnInitPaymentConfirmed -> { state -> state }
26412687
is Event.OnSendRequested -> { state -> state }
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.flipcash.app.messenger.internal
2+
3+
import com.flipcash.app.core.chat.ChatParticipant
4+
import com.flipcash.app.core.chat.ProfileOrigin
5+
import com.flipcash.app.messenger.internal.link.UserLinkLookup
6+
import com.flipcash.services.models.GetUserProfileError
7+
import com.flipcash.shared.chat.models.LinkCard
8+
import com.getcode.opencode.model.core.ID
9+
10+
/** Where a tapped `@handle` lands, once the handle has been looked up. iOS's `MentionDestination`. */
11+
internal sealed interface MentionDestination {
12+
/** The handle is the viewer's own. Matches a person card linking to the viewer. */
13+
data object OwnTipCard : MentionDestination
14+
15+
data class Profile(
16+
val participant: ChatParticipant.TipUser,
17+
val origin: ProfileOrigin,
18+
) : MentionDestination
19+
20+
/** Nobody has claimed [username]. */
21+
data class NoSuchAccount(val username: String) : MentionDestination
22+
23+
/** The lookup never got an answer. */
24+
data object LookupFailed : MentionDestination
25+
}
26+
27+
/**
28+
* Where a mention of [username] lands, given its [lookup] and the person the open DM is with, if
29+
* any.
30+
*
31+
* The counterpart opens the way the DM's own title does, with Mute and without Message, since
32+
* Message would lead straight back here. Anyone else gets the shortcuts and no Mute.
33+
*/
34+
internal fun mentionDestination(
35+
username: String,
36+
lookup: Result<LinkCard.User.State.Resolved>,
37+
counterpart: ID?,
38+
): MentionDestination = lookup.fold(
39+
onSuccess = { found ->
40+
if (found.isOwn) {
41+
MentionDestination.OwnTipCard
42+
} else {
43+
MentionDestination.Profile(
44+
participant = ChatParticipant.TipUser(found.userId, found.profile),
45+
origin = if (found.userId == counterpart) ProfileOrigin.Chat else ProfileOrigin.Mention,
46+
)
47+
}
48+
},
49+
onFailure = { error ->
50+
if (error.meansUnclaimed()) {
51+
MentionDestination.NoSuchAccount(username)
52+
} else {
53+
MentionDestination.LookupFailed
54+
}
55+
},
56+
)
57+
58+
// The server answers an unclaimed handle with an id-less profile, which the lookup fails as
59+
// `NoSuchAccount`; `NotFound` is the same answer if it ever comes back as an error instead.
60+
private fun Throwable.meansUnclaimed(): Boolean =
61+
this is UserLinkLookup.NoSuchAccount || this is GetUserProfileError.NotFound

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,22 @@ internal class LinkCardResolver(
111111
is LinkCard.User -> card.copy(state = userState(card.identity))
112112
}
113113

114+
/**
115+
* The person [identity] names, for a tapped `@handle`: the same lookup and the same stored
116+
* answers a person card for `flipcash.com/<handle>` uses, so a handle a card or an earlier tap
117+
* already resolved opens without a round trip.
118+
*
119+
* Unlike [resolve], the failure comes back whole. A card draws an unclaimed handle and a
120+
* dropped connection alike, but a tap has to tell the reader which one happened. A stored
121+
* answer does not outlive the server saying the handle names nobody.
122+
*/
123+
suspend fun lookUpUser(identity: LinkCard.User.Identity): Result<LinkCard.User.State.Resolved> {
124+
userAnswers[identity]?.let { return Result.success(it) }
125+
return user(identity)
126+
.onSuccess { memory.putUser(identity, it) }
127+
.onFailure { if (it.meansLinkIsGone()) memory.removeUser(identity) }
128+
}
129+
114130
/**
115131
* Drops what is held about [entropy] and tells the cards on screen to ask again.
116132
*

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
99
import androidx.compose.foundation.layout.imePadding
1010
import androidx.compose.runtime.Composable
1111
import androidx.compose.runtime.CompositionLocalProvider
12+
import androidx.compose.runtime.LaunchedEffect
1213
import androidx.compose.runtime.getValue
1314
import androidx.compose.runtime.mutableStateOf
1415
import androidx.compose.runtime.remember
@@ -36,6 +37,7 @@ import com.flipcash.app.core.links.ExternalLinkUriHandler
3637
import com.flipcash.app.core.tokens.TokenInfoEntry
3738
import com.flipcash.app.messenger.internal.ChatSubject
3839
import com.flipcash.app.messenger.internal.ChatViewModel
40+
import com.flipcash.app.messenger.internal.MentionDestination
3941
import com.flipcash.app.messenger.internal.link.CashCardTap
4042
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
4143
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge
@@ -50,6 +52,7 @@ import com.getcode.ui.theme.ScaffoldBarPlacement
5052
import com.getcode.ui.utils.rememberKeyboardController
5153
import dev.chrisbanes.haze.hazeSource
5254
import dev.chrisbanes.haze.rememberHazeState
55+
import kotlinx.coroutines.flow.filterIsInstance
5356

5457
@Composable
5558
internal fun MessengerScreen(viewModel: ChatViewModel) {
@@ -71,6 +74,27 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
7174
var barHeight by remember { mutableStateOf(0.dp) }
7275
val keyboard = rememberKeyboardController()
7376

77+
// Here rather than in the action handler because the lookup comes first: the tap only knows a
78+
// handle, and where it leads is the view model's to decide once the handle has an answer.
79+
LaunchedEffect(viewModel) {
80+
viewModel.eventFlow
81+
.filterIsInstance<ChatViewModel.Event.OpenMention>()
82+
.collect { (destination) ->
83+
keyboard.hideIfVisible {
84+
when (destination) {
85+
// As a person card linking to the viewer does: their card is on the You tab.
86+
MentionDestination.OwnTipCard ->
87+
navigator.rootNavigator.navigateAll(listOf(AppRoute.Tabs.Menu))
88+
is MentionDestination.Profile ->
89+
navigator.push(ChatStep.Profile(destination.participant, destination.origin))
90+
// Raised as dialogs by the view model; they never reach here.
91+
is MentionDestination.NoSuchAccount,
92+
MentionDestination.LookupFailed -> Unit
93+
}
94+
}
95+
}
96+
}
97+
7498
val chatActionHandler = { action: ChatAction ->
7599
when (action) {
76100
is ChatAction.AdvanceReadPointer -> {
@@ -159,6 +183,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
159183
}
160184
}
161185

186+
is ChatAction.OpenMention -> {
187+
viewModel.dispatchEvent(ChatViewModel.Event.MentionTapped(action.username))
188+
}
189+
162190
is ChatAction.ToggleSelection -> {
163191
viewModel.dispatchEvent(ChatViewModel.Event.ToggleMessageSelection(action.bubble))
164192
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
2323
import androidx.lifecycle.compose.collectAsStateWithLifecycle
2424
import com.flipcash.app.core.chat.ChatParticipant
2525
import com.flipcash.app.core.chat.ChatStep
26+
import com.flipcash.app.core.chat.ProfileOrigin
2627
import com.flipcash.app.core.chat.ReportSubject
2728
import com.flipcash.app.core.AppRoute
2829
import com.getcode.navigation.core.LocalCodeNavigator
@@ -66,18 +67,24 @@ import kotlin.time.Instant
6667
* The Message and Send Cash shortcuts follow the same split, the other way round: they show on a
6768
* group member's profile and not on a tip DM's, where they would only reopen the chat behind it.
6869
* [profileShortcutRecipient] has the whole rule. Share shows on every person's profile.
70+
*
71+
* A tapped `@handle` naming someone other than a tip DM's counterpart opens with
72+
* [ProfileOrigin.Mention]. That person is not who the chat is with, so the screen leaves the chat
73+
* out, as a `flipcash.com` link's profile does: no Mute, and Message shows.
6974
*/
7075
@Composable
7176
internal fun ChatProfileScreen(
7277
viewModel: ChatProfileViewModel,
7378
chatViewModel: ChatViewModel,
79+
origin: ProfileOrigin = ProfileOrigin.Chat,
7480
) {
7581
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()
7682
val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle()
7783

7884
PersonProfileScreen(
7985
viewModel = viewModel,
80-
chat = ProfileChat(
86+
chat = profileChat(
87+
origin = origin,
8188
chatId = chatState.chatId,
8289
chatType = chatState.chatType,
8390
viewerState = chatState.viewerState,
@@ -87,6 +94,20 @@ internal fun ChatProfileScreen(
8794
)
8895
}
8996

97+
/**
98+
* The chat behind a profile opened from inside it, or null for a mention, whose person the chat is
99+
* not with.
100+
*/
101+
internal fun profileChat(
102+
origin: ProfileOrigin,
103+
chatId: ChatId?,
104+
chatType: ChatType,
105+
viewerState: ViewerState?,
106+
): ProfileChat? = when (origin) {
107+
ProfileOrigin.Chat -> ProfileChat(chatId, chatType, viewerState)
108+
ProfileOrigin.Mention -> null
109+
}
110+
90111
/**
91112
* The conversation a person's profile was opened from, for the parts of the screen that act on the
92113
* chat rather than the person: the mute row and the muted chip.

0 commit comments

Comments
 (0)