From 83ef066eed55ebfeb8a69de9d4205403bf4916fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 17:45:38 -0400 Subject: [PATCH 1/2] 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. --- .../com/flipcash/app/core/chat/ChatStep.kt | 22 ++- .../core/src/main/res/values/strings.xml | 4 + .../flipcash/app/messenger/ChatFlowScreen.kt | 7 +- .../app/messenger/internal/ChatViewModel.kt | 50 +++++- .../messenger/internal/MentionDestination.kt | 61 ++++++++ .../internal/link/LinkCardResolver.kt | 16 ++ .../internal/screens/MessengerScreen.kt | 28 ++++ .../screens/profile/ChatProfileScreen.kt | 14 +- .../screens/profile/ProfileShortcuts.kt | 17 +- .../internal/MentionDestinationTest.kt | 89 +++++++++++ .../profile/ProfileShortcutRecipientTest.kt | 22 +++ .../flipcash/shared/chat/models/ChatAction.kt | 6 + .../shared/chat/models/ChatListItem.kt | 24 +++ .../shared/chat/ui/MentionDetection.kt | 41 +++++ .../flipcash/shared/chat/ui/MentionPills.kt | 146 ++++++++++++++++++ .../flipcash/shared/chat/ui/MessageBubble.kt | 81 ++++++++-- .../chat/models/SplitAroundLinkCardTest.kt | 23 +++ .../shared/chat/ui/MentionDetectionTest.kt | 99 ++++++++++++ .../shared/chat/ui/MentionedTextTest.kt | 66 ++++++++ 19 files changed, 787 insertions(+), 29 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MentionDestination.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt create mode 100644 apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index a2bb6c3c6e..fc909b848c 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -75,7 +75,10 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable - data class Profile(val contact: ChatParticipant): ChatStep + data class Profile( + val contact: ChatParticipant, + val origin: ProfileOrigin = ProfileOrigin.Chat, + ): ChatStep /** * 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 { @Serializable data class Reactors(val chatId: ChatId, val messageId: Long) : ChatStep, Sheet, HalfSheet } + +/** What opened a [ChatStep.Profile], which decides the actions it offers. */ +@Serializable +enum class ProfileOrigin { + /** + * The chat's own title, or a member's picture or reactor row. The chat decides: a tip DM's + * profile is the other person in it, so it offers Mute and not Message. + */ + Chat, + + /** + * A tapped `@handle` naming someone other than the DM's counterpart. That person is not who + * the chat is with, so Mute would act on the wrong chat and Message and Send Cash have + * somewhere new to go. iOS's `UserProfileOrigin.mention`. + */ + Mention, +} diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b2b0235051..6d8c993a0e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -185,6 +185,10 @@ Join to Collect Join this chat to collect cash sent in it. + No Such Account + Nobody has claimed @%1$s + Couldn\'t Open Profile + Please check your connection and try again Enter Solana address diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 94b0bf6d97..38fd6c3f96 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -19,6 +19,7 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatSendResult import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.chat.GroupInviteResult import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.messenger.internal.ChatSubject @@ -110,7 +111,7 @@ private fun chatEntryProvider( } annotatedEntry { step -> - FlowChatProfileScreen(step.contact) + FlowChatProfileScreen(step.contact, step.origin) } annotatedEntry { FlowGroupProfileScreen() @@ -310,7 +311,7 @@ private fun FlowGroupInviteSheet() { } @Composable -private fun FlowChatProfileScreen(participant: ChatParticipant) { +private fun FlowChatProfileScreen(participant: ChatParticipant, origin: ProfileOrigin) { val viewModel = flowSharedViewModel() val flowNavigator = rememberFlowNavigator() @@ -318,7 +319,7 @@ private fun FlowChatProfileScreen(participant: ChatParticipant) { viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant)) } - ChatProfileScreen(viewModel, flowSharedViewModel()) + ChatProfileScreen(viewModel, flowSharedViewModel(), origin) LaunchedEffect(viewModel) { viewModel.eventFlow 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 e09642c9fa..425eacd230 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 @@ -101,6 +101,7 @@ import com.flipcash.shared.chat.reactions.ReactionStripComposer import com.flipcash.shared.chat.reactions.SelfReaction import com.flipcash.shared.chat.readOnly import com.flipcash.shared.chat.resolveCapabilities +import com.flipcash.shared.chat.ui.detectMentions import com.flipcash.shared.chat.ui.detectUrls import com.flipcash.shared.chat.ui.linkableText import com.flipcash.shared.chat.withinWindows @@ -581,6 +582,15 @@ internal class ChatViewModel @Inject constructor( data object NavigateToInitPayment : Event data object PresentDepositOptions : Event data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event + + /** The reader tapped a mention of [username] in the transcript. */ + data class MentionTapped(val username: String) : Event + + /** + * Where a tapped mention resolved to, for the screen to open. Only the destinations that + * open something: an unclaimed handle or a failed lookup is a dialog raised here. + */ + data class OpenMention(val destination: MentionDestination) : Event data object OnConfirmRequested : Event /** Confirm the DM-opening fee. The amount comes from the fee, not from the keypad. */ @@ -850,8 +860,13 @@ internal class ChatViewModel @Inject constructor( // which meant a chat painted nothing until every link in the first window had // been round-tripped, and that every link in every mapped message was queried // whether or not the reader ever scrolled to it. - val linkCard = enriched.linkableText() - ?.let { text -> linkCardClassifier.firstCard(detectUrls(text)) } + val linkableText = enriched.linkableText() + val links = linkableText?.let(::detectUrls).orEmpty() + val linkCard = linkCardClassifier.firstCard(links) + + // 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. + val mentions = linkableText?.let { detectMentions(it, links) }.orEmpty() // Noted while the voucher and the message it came on are in the same hand; // see [ClaimReplyTargets]. Skipped for the reader's own messages, which is what @@ -917,6 +932,7 @@ internal class ChatViewModel @Inject constructor( // after the first page does. senderId = message.senderId?.takeIf { !message.isFromSelf }, linkCard = linkCard, + mentions = mentions, // `splitAroundLinkCard` keeps these pills on the last row only. reactionPills = storedReactions.pills, selfReactions = storedReactions.selfReactions, @@ -1152,6 +1168,7 @@ internal class ChatViewModel @Inject constructor( initChatHandlers() initLinkCardFreshness() initClaimReplies() + initMentionHandlers() initDraftHandlers() initGroupAnalytics() @@ -1248,6 +1265,33 @@ internal class ChatViewModel @Inject constructor( .launchIn(viewModelScope) } + /** + * Opens where a tapped `@handle` leads. Looked up through [linkCardResolver], so a handle a + * person card or an earlier tap already resolved opens without a round trip. + * + * An unclaimed handle and a failed lookup get different dialogs: one is an answer, the other + * is worth trying again. + */ + private fun initMentionHandlers() { + eventFlow.filterIsInstance() + .onEach { event -> + val lookup = linkCardResolver.lookUpUser(LinkCard.User.Identity.ByUsername(event.username)) + val counterpart = (stateFlow.value.participant as? ChatParticipant.TipUser)?.userId + when (val destination = mentionDestination(event.username, lookup, counterpart)) { + is MentionDestination.NoSuchAccount -> BottomBarManager.showInfo( + title = resources.getString(R.string.title_mentionNoSuchAccount), + message = resources.getString(R.string.description_mentionNoSuchAccount, destination.username), + ) + MentionDestination.LookupFailed -> BottomBarManager.showError( + title = resources.getString(R.string.title_mentionLookupFailed), + message = resources.getString(R.string.description_mentionLookupFailed), + ) + else -> dispatchEvent(Event.OpenMention(destination)) + } + } + .launchIn(viewModelScope) + } + /** * Replies to the voucher with a thank-you, once a link tapped here has actually been collected. * @@ -2636,6 +2680,8 @@ internal class ChatViewModel @Inject constructor( Event.NavigateToInitPayment -> { state -> state.copy(sendProgress = LoadingSuccessState()) } is Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } + is Event.MentionTapped -> { state -> state } + is Event.OpenMention -> { state -> state } is Event.OnConfirmRequested -> { state -> state } is Event.OnInitPaymentConfirmed -> { state -> state } is Event.OnSendRequested -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MentionDestination.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MentionDestination.kt new file mode 100644 index 0000000000..66c38b55f0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MentionDestination.kt @@ -0,0 +1,61 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.messenger.internal.link.UserLinkLookup +import com.flipcash.services.models.GetUserProfileError +import com.flipcash.shared.chat.models.LinkCard +import com.getcode.opencode.model.core.ID + +/** Where a tapped `@handle` lands, once the handle has been looked up. iOS's `MentionDestination`. */ +internal sealed interface MentionDestination { + /** The handle is the viewer's own. Matches a person card linking to the viewer. */ + data object OwnTipCard : MentionDestination + + data class Profile( + val participant: ChatParticipant.TipUser, + val origin: ProfileOrigin, + ) : MentionDestination + + /** Nobody has claimed [username]. */ + data class NoSuchAccount(val username: String) : MentionDestination + + /** The lookup never got an answer. */ + data object LookupFailed : MentionDestination +} + +/** + * Where a mention of [username] lands, given its [lookup] and the person the open DM is with, if + * any. + * + * The counterpart opens the way the DM's own title does, with Mute and without Message, since + * Message would lead straight back here. Anyone else gets the shortcuts and no Mute. + */ +internal fun mentionDestination( + username: String, + lookup: Result, + counterpart: ID?, +): MentionDestination = lookup.fold( + onSuccess = { found -> + if (found.isOwn) { + MentionDestination.OwnTipCard + } else { + MentionDestination.Profile( + participant = ChatParticipant.TipUser(found.userId, found.profile), + origin = if (found.userId == counterpart) ProfileOrigin.Chat else ProfileOrigin.Mention, + ) + } + }, + onFailure = { error -> + if (error.meansUnclaimed()) { + MentionDestination.NoSuchAccount(username) + } else { + MentionDestination.LookupFailed + } + }, +) + +// The server answers an unclaimed handle with an id-less profile, which the lookup fails as +// `NoSuchAccount`; `NotFound` is the same answer if it ever comes back as an error instead. +private fun Throwable.meansUnclaimed(): Boolean = + this is UserLinkLookup.NoSuchAccount || this is GetUserProfileError.NotFound 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 7ad0d6d83d..47ac3e1241 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 @@ -111,6 +111,22 @@ internal class LinkCardResolver( is LinkCard.User -> card.copy(state = userState(card.identity)) } + /** + * The person [identity] names, for a tapped `@handle`: the same lookup and the same stored + * answers a person card for `flipcash.com/` uses, so a handle a card or an earlier tap + * already resolved opens without a round trip. + * + * Unlike [resolve], the failure comes back whole. A card draws an unclaimed handle and a + * dropped connection alike, but a tap has to tell the reader which one happened. A stored + * answer does not outlive the server saying the handle names nobody. + */ + suspend fun lookUpUser(identity: LinkCard.User.Identity): Result { + userAnswers[identity]?.let { return Result.success(it) } + return user(identity) + .onSuccess { memory.putUser(identity, it) } + .onFailure { if (it.meansLinkIsGone()) memory.removeUser(identity) } + } + /** * Drops what is held about [entropy] and tells the cards on screen to ask again. * 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 a567961c54..616a0b1cd5 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,6 +36,7 @@ import com.flipcash.app.core.links.ExternalLinkUriHandler import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.MentionDestination import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge @@ -49,6 +51,7 @@ import com.getcode.ui.theme.ScaffoldBarPlacement import com.getcode.ui.utils.rememberKeyboardController import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.flow.filterIsInstance @Composable internal fun MessengerScreen(viewModel: ChatViewModel) { @@ -70,6 +73,27 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { var barHeight by remember { mutableStateOf(0.dp) } val keyboard = rememberKeyboardController() + // Here rather than in the action handler because the lookup comes first: the tap only knows a + // handle, and where it leads is the view model's to decide once the handle has an answer. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .collect { (destination) -> + keyboard.hideIfVisible { + when (destination) { + // As a person card linking to the viewer does: their card is on the You tab. + MentionDestination.OwnTipCard -> + navigator.rootNavigator.navigateAll(listOf(AppRoute.Tabs.Menu)) + is MentionDestination.Profile -> + navigator.push(ChatStep.Profile(destination.participant, destination.origin)) + // Raised as dialogs by the view model; they never reach here. + is MentionDestination.NoSuchAccount, + MentionDestination.LookupFailed -> Unit + } + } + } + } + val chatActionHandler = { action: ChatAction -> when (action) { is ChatAction.AdvanceReadPointer -> { @@ -158,6 +182,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + is ChatAction.OpenMention -> { + viewModel.dispatchEvent(ChatViewModel.Event.MentionTapped(action.username)) + } + is ChatAction.ToggleSelection -> { viewModel.dispatchEvent(ChatViewModel.Event.ToggleMessageSelection(action.bubble)) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index a9670f6fd6..b81dafaf97 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.core.AppRoute import com.getcode.navigation.core.LocalCodeNavigator @@ -34,7 +35,6 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.asSubject import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R -import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme @@ -66,17 +66,22 @@ import kotlin.time.Instant * The Message and Send Cash shortcuts follow the same split, the other way round: they show on a * group member's profile and not on a tip DM's, where they would only reopen the chat behind it. * [profileShortcutRecipient] has the whole rule. + * + * A tapped `@handle` naming someone other than a tip DM's counterpart opens with + * [ProfileOrigin.Mention], which takes the group member's shape: shortcuts, and no Mute, since the + * DM's mute is not this person's. */ @Composable internal fun ChatProfileScreen( viewModel: ChatProfileViewModel, chatViewModel: ChatViewModel, + origin: ProfileOrigin = ProfileOrigin.Chat, ) { val flowNavigator = rememberFlowNavigator() val navigator = LocalCodeNavigator.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() - val isTipDm = chatState.chatType == ChatType.TIP_DM + val showsMute = profileShowsMute(chatState.chatType, origin) CodeScaffold( topBar = { @@ -91,7 +96,7 @@ internal fun ChatProfileScreen( // that asks someone else to look, which sits above the one that ends the conversation. // Same shape as the group's profile, where leaving holds the last place. items = buildList> { - if (isTipDm) { + if (showsMute) { add(MuteDm) } add(ReportUser) @@ -102,12 +107,13 @@ internal fun ChatProfileScreen( participant = state.participant, chatType = chatState.chatType, selfId = state.selfId, + origin = origin, ) ProfileHeader( participant = state.participant, joinDate = state.joinDate, // Only where the mute is this person's chat; see the KDoc above. - viewerState = chatState.viewerState.takeIf { isTipDm }, + viewerState = chatState.viewerState.takeIf { showsMute }, // Not flowNavigator, for the reason Report isn't: the DM is a top-level route, // so LocalCodeNavigator hands it up and it opens over this chat. shortcuts = recipient?.let { user -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt index 8157d37ead..67642d76e7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcuts.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ProfileOrigin // Used by the held-back Send Cash shortcut below. // import com.flipcash.app.messenger.internal.screens.components.CondensedSymbolFontSize import com.flipcash.features.messenger.R @@ -33,12 +34,21 @@ import com.flipcash.services.models.chat.ChatType import com.getcode.opencode.model.core.ID import com.getcode.theme.CodeTheme +/** + * Whether the profile offers the chat's Mute row: only for a tip DM's own counterpart, opened the + * way the title opens it. A member's profile in a group would mute the whole group from a screen + * that names one person, and a mentioned person is not who the DM is with. + */ +internal fun profileShowsMute(chatType: ChatType, origin: ProfileOrigin): Boolean = + origin == ProfileOrigin.Chat && chatType == ChatType.TIP_DM + /** * Who the profile's Message and Send Cash shortcuts would open a DM with, or null to leave them * out. * - * - Not from a tip DM: a DM's profile is always the other person in it, so the shortcuts would - * only reopen the chat the viewer came from. + * - Not from a tip DM's own title: a DM's profile is always the other person in it, so the + * shortcuts would only reopen the chat the viewer came from. A mention of someone else in that + * DM ([ProfileOrigin.Mention]) is not the person the chat is with, so it keeps them. * - Not for the viewer's own profile: there is no DM with yourself. * - Only for a [ChatParticipant.TipUser]: the DM route is addressed by user id, which a contact * doesn't have. Contact DMs don't reach this screen anyway. @@ -50,8 +60,9 @@ internal fun profileShortcutRecipient( participant: ChatParticipant?, chatType: ChatType, selfId: ID?, + origin: ProfileOrigin = ProfileOrigin.Chat, ): ChatParticipant.TipUser? { - if (chatType == ChatType.TIP_DM) return null + if (origin == ProfileOrigin.Chat && chatType == ChatType.TIP_DM) return null val user = participant as? ChatParticipant.TipUser ?: return null if (selfId != null && user.userId == selfId) return null return user diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt new file mode 100644 index 0000000000..b07c312d94 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt @@ -0,0 +1,89 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.messenger.internal.link.UserLinkLookup +import com.flipcash.app.messenger.internal.screens.profile.profileShortcutRecipient +import com.flipcash.app.messenger.internal.screens.profile.profileShowsMute +import com.flipcash.services.models.GetUserProfileError +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatType +import com.flipcash.shared.chat.models.LinkCard +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** Where a tapped `@handle` lands once its lookup answers. Ports iOS's `MentionDestinationTests`. */ +class MentionDestinationTest { + + private val otherId = listOf(2.toByte()) + private val counterpartId = listOf(3.toByte()) + private val selfId = listOf(1.toByte()) + + private fun profile(userId: List) = + UserProfile.Empty.copy(displayName = "Satoshi", username = "satoshi", userId = userId) + + private fun found(userId: List, isOwn: Boolean = false) = Result.success( + LinkCard.User.State.Resolved( + userId = userId, + isOwn = isOwn, + profile = profile(userId), + name = "Satoshi", + handle = "@satoshi", + joined = null, + ) + ) + + @Test + fun `someone else opens their profile with chat actions`() { + assertEquals( + MentionDestination.Profile(ChatParticipant.TipUser(otherId, profile(otherId)), ProfileOrigin.Mention), + mentionDestination("satoshi", found(otherId), counterpart = counterpartId), + ) + } + + @Test + fun `the DM counterpart opens the way the title does`() { + assertEquals( + MentionDestination.Profile(ChatParticipant.TipUser(counterpartId, profile(counterpartId)), ProfileOrigin.Chat), + mentionDestination("satoshi", found(counterpartId), counterpart = counterpartId), + ) + } + + @Test + fun `the viewer's own handle opens their tip card`() { + assertEquals( + MentionDestination.OwnTipCard, + mentionDestination("satoshi", found(otherId, isOwn = true), counterpart = null), + ) + } + + @Test + fun `an unclaimed handle is no such account`() { + val noAccount = UserLinkLookup.NoSuchAccount(LinkCard.User.Identity.ByUsername("nobody")) + assertEquals( + MentionDestination.NoSuchAccount("nobody"), + mentionDestination("nobody", Result.failure(noAccount), counterpart = null), + ) + assertEquals( + MentionDestination.NoSuchAccount("nobody"), + mentionDestination("nobody", Result.failure(GetUserProfileError.NotFound()), counterpart = null), + ) + } + + @Test + fun `a failed lookup is not read as unclaimed`() { + assertEquals( + MentionDestination.LookupFailed, + mentionDestination("satoshi", Result.failure(IOException()), counterpart = null), + ) + } + + @Test + fun `a mention offers chat actions but not mute`() { + val other = ChatParticipant.TipUser(otherId, profile(otherId)) + assertEquals(other, profileShortcutRecipient(other, ChatType.TIP_DM, selfId, ProfileOrigin.Mention)) + assertFalse(profileShowsMute(ChatType.TIP_DM, ProfileOrigin.Mention)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt index 104dbd5f69..1248de6634 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileShortcutRecipientTest.kt @@ -1,12 +1,15 @@ package com.flipcash.app.messenger.internal.screens.profile import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatType import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue /** When a profile offers Message and Send Cash. Kept in step with the iOS profile's rule. */ class ProfileShortcutRecipientTest { @@ -47,4 +50,23 @@ class ProfileShortcutRecipientTest { assertNull(profileShortcutRecipient(contact, ChatType.GROUP, self)) assertNull(profileShortcutRecipient(null, ChatType.GROUP, self)) } + + @Test + fun `someone mentioned in a tip DM gets the shortcuts, since the chat is not with them`() { + assertEquals(member, profileShortcutRecipient(member, ChatType.TIP_DM, self, ProfileOrigin.Mention)) + } + + @Test + fun `a mention of the viewer still doesn't`() { + val me = member.copy(userId = self) + assertNull(profileShortcutRecipient(me, ChatType.TIP_DM, self, ProfileOrigin.Mention)) + } + + @Test + fun `only a tip DM's own counterpart offers Mute`() { + assertTrue(profileShowsMute(ChatType.TIP_DM, ProfileOrigin.Chat)) + assertFalse(profileShowsMute(ChatType.TIP_DM, ProfileOrigin.Mention)) + assertFalse(profileShowsMute(ChatType.GROUP, ProfileOrigin.Chat)) + assertFalse(profileShowsMute(ChatType.GROUP, ProfileOrigin.Mention)) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index c54da580f9..3be61318e0 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -35,6 +35,12 @@ sealed interface ChatAction { * own fee sheet asks for it. */ data class OpenUser(val userId: ID, val profile: UserProfile, val isOwn: Boolean) : ChatAction + + /** + * Opens whoever a tapped `@handle` names. Only the handle travels: nothing is looked up until + * the tap, and the handler decides where it leads once it has. + */ + data class OpenMention(val username: String) : ChatAction data object ViewProfile : ChatAction /** diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 3c56637ab9..9ae6456c18 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -4,6 +4,7 @@ import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.SelfReaction +import com.flipcash.shared.chat.ui.DetectedMention import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -64,6 +65,15 @@ sealed interface ChatListItem { * can see both the router and the network; `chat-ui` only draws what it is handed. */ val linkCard: LinkCard? = null, + /** + * The `@handle`s in the text this row draws, in that text's own offsets. Detected once over + * the whole message, beside the links, so a handle inside a link is the link's; then + * [splitAroundLinkCard] rebases each into the row it falls in. + * + * Kept apart from [linkCard] and the underlined links: a mention takes no part in choosing + * a card or in where the message splits. + */ + val mentions: List = emptyList(), /** * Who sent this, when that is not implied. Null for the viewer's own messages and for every * message in a DM; set only for another member's message in a group. @@ -247,6 +257,11 @@ fun ChatListItem.ContentBubble.splitAroundLinkCard(): List mentions.rebased(from = 0, until = leadingEnd) + MessagePart.Card -> emptyList() + MessagePart.Trailing -> mentions.rebased(from = trailingStart, until = text.length) + }, isFirstRow = index == 0, isLastRow = index == parts.lastIndex, reactionPills = reactionPills.takeIf { index == parts.lastIndex }.orEmpty(), @@ -254,6 +269,15 @@ fun ChatListItem.ContentBubble.splitAroundLinkCard(): List.rebased(from: Int, until: Int): List = + filter { it.start >= from && it.end <= until } + .map { it.copy(start = it.start - from, end = it.end - from) } + /** * Unicode punctuation, the general categories iOS's `CharacterSet.punctuationCharacters` covers. * Symbols such as `$` and emoji are not punctuation, so they stay with their words. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt new file mode 100644 index 0000000000..be344bd183 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt @@ -0,0 +1,41 @@ +package com.flipcash.shared.chat.ui + +/** + * One `@handle` found in message text: the span it occupies, covering the `@` and the handle, and + * the handle it names, lowercased the way usernames are stored. + * + * Offsets are UTF-16 code units, half-open, matching [DetectedUrl] and `NSRange` on iOS. + */ +data class DetectedMention( + val start: Int, + val end: Int, + val username: String, +) + +/** + * Every `@handle` in [text] that does not overlap one of [links], in the order they appear. + * + * Pure and synchronous: whether the handle belongs to anyone is only known once it is tapped. + * iOS's `MentionDetector` is the reference, and the pattern is the same one. + * + * The character before the `@` must not be a handle character, `.` or `@`, so an email address or + * `@@name` is not a mention. The handle ends at the first character a handle cannot hold, and a + * handle running straight into more handle characters or another `@` is dropped rather than cut + * short. A handle inside a link (`x.com/@jeff`) is left to the link. + */ +fun detectMentions(text: String, links: List): List { + if ('@' !in text) return emptyList() + return MENTION_PATTERN.findAll(text).mapNotNull { match -> + val mention = match.groups[1] ?: return@mapNotNull null + val handle = match.groups[2] ?: return@mapNotNull null + val start = mention.range.first + val end = mention.range.last + 1 + if (links.any { it.start < end && start < it.end }) return@mapNotNull null + DetectedMention(start = start, end = end, username = handle.value.lowercase()) + }.toList() +} + +// The preceding character is matched and left out of the mention's range, as iOS does for want of +// a lookbehind in Swift Regex, so the two platforms run one pattern. +private val MENTION_PATTERN = + Regex("""(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])""") diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt new file mode 100644 index 0000000000..c29c9a07e0 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt @@ -0,0 +1,146 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * How a mention is drawn: a rounded pill behind the handle, so it reads as a person rather than a + * web link. iOS's `MentionPill`, value for value. + */ +internal object MentionPill { + val fill = Color.White.copy(alpha = 0.14f) + + /** How far the pill reaches past the handle's letters, each side. */ + val padding = 6.dp + + /** The space between the pill and the neighbouring words. */ + val gap = 3.dp + + val cornerRadius = 6.dp + + /** The room held open either side of the handle: the pill's padding, then the gap. */ + val spacing = padding + gap +} + +/** + * A text bubble's body with its links and mentions in place, and where each handle's letters sit + * in it. + * + * The body is not [text] verbatim: each mention is bracketed by a placeholder [MentionPill.spacing] + * wide, which is what makes room for the pill without drawing over the words beside it. iOS kerns + * the neighbouring characters for the same room; Compose has no per-character kern, and a + * placeholder of known width is the nearest thing. Its stand-in character is a no-break space, so + * the line can still break before the leading one but never between it and the `@`, nor between + * the handle and the trailing one. + */ +internal class MentionedText( + val text: AnnotatedString, + /** Each handle's letters, `@` included, in [text]'s offsets. The pills are drawn from these. */ + val handles: List, +) + +internal const val MENTION_SPACING_SLOT = "mentionSpacing" + +private const val MENTION_SPACING_STAND_IN = " " + +internal fun buildMentionedText( + text: String, + mentions: List, + linkStyle: SpanStyle, + onMentionClick: ((String) -> Unit)?, +): MentionedText { + val ordered = mentions.sortedBy { it.start } + val handles = mutableListOf() + val body = buildAnnotatedString { + var cursor = 0 + ordered.forEach { mention -> + // Out of order or overlapping only if the caller handed over ranges from other text. + if (mention.start < cursor || mention.end > text.length) return@forEach + append(text, cursor, mention.start) + val tapStart = length + appendInlineContent(MENTION_SPACING_SLOT, MENTION_SPACING_STAND_IN) + val handleStart = length + append(text, mention.start, mention.end) + handles += TextRange(handleStart, length) + appendInlineContent(MENTION_SPACING_SLOT, MENTION_SPACING_STAND_IN) + // Over the spacing as well as the letters, so the whole pill takes the tap. + if (onMentionClick != null) { + addLink( + LinkAnnotation.Clickable(tag = mention.username) { onMentionClick(mention.username) }, + start = tapStart, + end = length, + ) + } + cursor = mention.end + } + append(text, cursor, text.length) + + // A link never overlaps a mention -- detection drops the mention -- so each link moves by + // the two placeholders of every mention before it. + detectUrls(text).forEach { link -> + val shift = 2 * ordered.count { it.start < link.start } + addLink( + LinkAnnotation.Url(url = link.url, styles = TextLinkStyles(style = linkStyle)), + start = link.start + shift, + end = link.end + shift, + ) + } + } + return MentionedText(body, handles) +} + +/** The placeholder either side of a handle, for the body's `inlineContent`. */ +internal fun mentionSpacingContent(density: Density): Pair = + MENTION_SPACING_SLOT to InlineTextContent( + Placeholder( + width = with(density) { MentionPill.spacing.toSp() }, + height = 1.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom, + ), + ) { } + +/** + * The pill behind each line [handle] covers: its letters plus [MentionPill.padding] each side, the + * line's full height. A handle that wraps gets a pill per line, as on iOS. + */ +internal fun TextLayoutResult.mentionPillRects(handle: TextRange, padding: Float): List> { + if (handle.collapsed || handle.end > layoutInput.text.length) return emptyList() + val firstLine = getLineForOffset(handle.start) + val lastLine = getLineForOffset(handle.end - 1) + return (firstLine..lastLine).mapNotNull { line -> + val from = maxOf(handle.start, getLineStart(line)) + val until = minOf(handle.end, getLineEnd(line, visibleEnd = true)) + if (from >= until) return@mapNotNull null + val left = getBoundingBox(from).left - padding + val right = getBoundingBox(until - 1).right + padding + val top = getLineTop(line) + Offset(left, top) to Size(right - left, getLineBottom(line) - top) + } +} + +internal fun DrawScope.drawMentionPills(layout: TextLayoutResult, handles: List) { + val padding = MentionPill.padding.toPx() + val radius = CornerRadius(MentionPill.cornerRadius.toPx()) + handles.forEach { handle -> + layout.mentionPillRects(handle, padding).forEach { (topLeft, size) -> + drawRoundRect(color = MentionPill.fill, topLeft = topLeft, size = size, cornerRadius = radius) + } + } +} 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 13176d171a..b2434af2e1 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 @@ -27,10 +27,14 @@ import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape @@ -44,6 +48,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -141,6 +146,12 @@ fun ContentBubble( // A split message's citation goes on its first row and its marker on its last, so the // pieces read as one message stacked rather than as three. val quote = item.quote?.takeIf { item.isFirstRow } + // Dropped behind the backdrop, as the cash bubble's tap is: there a tap dismisses it. + val onMentionClick: ((String) -> Unit)? = if (interactive) { + { username -> actionHandler(ChatAction.OpenMention(username)) } + } else { + null + } val onQuoteClick = quote?.takeIf { interactive }?.let { cited -> { actionHandler(ChatAction.JumpToMessage(cited.messageId)) } } @@ -175,6 +186,8 @@ fun ContentBubble( is MessageContent.Text -> TextBubble( modifier = modifier, text = item.partText ?: content.text, + mentions = item.mentions, + onMentionClick = onMentionClick, isFromSelf = item.isFromSelf, position = position, maxWidth = bubbleMaxWidth, @@ -231,6 +244,8 @@ fun ContentBubble( is MessageContent.Reply -> TextBubble( modifier = modifier, text = item.partText ?: content.linkableText().orEmpty(), + mentions = item.mentions, + onMentionClick = onMentionClick, isFromSelf = item.isFromSelf, position = position, maxWidth = bubbleMaxWidth, @@ -355,6 +370,8 @@ private fun TextBubble( modifier: Modifier = Modifier, isEdited: Boolean = false, isTombstone: Boolean = false, + mentions: List = emptyList(), + onMentionClick: ((String) -> Unit)? = null, quote: ChatQuote? = null, onQuoteClick: (() -> Unit)? = null, onQuoteLongClick: (() -> Unit)? = null, @@ -393,12 +410,29 @@ private fun TextBubble( color = CodeTheme.colors.textMain, textDecoration = TextDecoration.Underline, ) + // Read through the latest handler, so a recomposition that hands over a new lambda does + // not rebuild the body; only whether mentions can be tapped at all is part of its key. + val currentOnMentionClick by rememberUpdatedState(onMentionClick) + val mentionsTappable = onMentionClick != null // A tombstone carries no link and nothing worth selecting; it is a notice, not a message. - val body = if (isTombstone) { - AnnotatedString(bodyString) + val mentioned = if (isTombstone) { + null } else { - rememberRichText(text = bodyString, annotators = listOf(UrlAnnotator(linkStyle))) + remember(bodyString, mentions, linkStyle, mentionsTappable) { + buildMentionedText( + text = bodyString, + mentions = mentions, + linkStyle = linkStyle, + onMentionClick = if (mentionsTappable) { + { username -> currentOnMentionClick?.invoke(username) } + } else { + null + }, + ) + } } + val body = mentioned?.text ?: AnnotatedString(bodyString) + val handles = mentioned?.handles.orEmpty() val bodyStyle = CodeTheme.typography.textMedium.copy( fontWeight = FontWeight.Medium, fontStyle = if (isTombstone) FontStyle.Italic else FontStyle.Normal, @@ -434,19 +468,25 @@ private fun TextBubble( } else { body } - val inlineContent = if (isEdited) { - mapOf( - EDITED_MARKER_SLOT to InlineTextContent( - Placeholder( - width = reservation, - height = 1.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom, - ), - ) { }, - ) - } else { - emptyMap() + val inlineContent = buildMap { + if (isEdited) { + put( + EDITED_MARKER_SLOT, + InlineTextContent( + Placeholder( + width = reservation, + height = 1.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom, + ), + ) { }, + ) + } + if (handles.isNotEmpty()) { + val (slot, spacing) = mentionSpacingContent(density) + put(slot, spacing) + } } + var bodyLayout by remember { mutableStateOf(null) } // No SelectionContainer: long-press is the transcript's selection gesture, and a text // selection handle inside the bubble would consume it before the row ever sees it. Copying @@ -455,11 +495,20 @@ private fun TextBubble( // reply reads as one message rather than as a quote with a message under it. val bodyText = @Composable { Text( - modifier = Modifier.padding(horizontal = bodyInset), + modifier = Modifier + .padding(horizontal = bodyInset) + .then( + if (handles.isEmpty()) { + Modifier + } else { + Modifier.drawBehind { bodyLayout?.let { drawMentionPills(it, handles) } } + } + ), text = laidOut, inlineContent = inlineContent, style = bodyStyle, color = bodyColor, + onTextLayout = { bodyLayout = it }, ) } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt index ca1265751e..21c5f07e75 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SplitAroundLinkCardTest.kt @@ -2,6 +2,8 @@ package com.flipcash.shared.chat.models import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.reactions.ReactionPill +import com.flipcash.shared.chat.ui.DetectedMention +import com.flipcash.shared.chat.ui.detectMentions import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -35,6 +37,8 @@ class SplitAroundLinkCardTest { state = LinkCard.Cash.State.Unresolved, ), reactionPills = reactionPills, + // The link carries no `@`, so detecting without it excludes nothing here. + mentions = detectMentions(text, emptyList()), ).splitAroundLinkCard() @Test @@ -167,4 +171,23 @@ class SplitAroundLinkCardTest { assertNull(row.part) assertEquals("1-0", row.itemKey) } + + @Test + fun `a mention after the card is rebased into the trailing row`() { + val rows = split("here $link from @jeff") + + assertEquals(listOf(MessagePart.Leading, MessagePart.Card, MessagePart.Trailing), rows.map { it.part }) + assertEquals(emptyList(), rows[0].mentions) + assertEquals(emptyList(), rows[1].mentions) + assertEquals("from @jeff", rows[2].partText) + assertEquals(listOf(DetectedMention(start = 5, end = 10, username = "jeff")), rows[2].mentions) + } + + @Test + fun `a mention before the card keeps its offsets in the leading row`() { + val rows = split("hi @jeff $link") + + assertEquals(listOf(DetectedMention(start = 3, end = 8, username = "jeff")), rows[0].mentions) + assertEquals(emptyList(), rows[1].mentions) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt new file mode 100644 index 0000000000..fc577b5119 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionDetectionTest.kt @@ -0,0 +1,99 @@ +package com.flipcash.shared.chat.ui + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * One-for-one with iOS's `MentionDetectorTests`. Robolectric because the link cases run the real + * [detectUrls], which reads `android.util.Patterns`. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class MentionDetectionTest { + + private fun handles(text: String, links: List = emptyList()): List = + detectMentions(text, links).map { it.username } + + @Test + fun `a handle at the start of the text is a mention`() { + val mentions = detectMentions("@jeff hi", emptyList()) + assertEquals(listOf("jeff"), mentions.map { it.username }) + assertEquals(0 to 5, mentions.first().let { it.start to it.end }) + } + + @Test + fun `the range covers the at sign and the handle, not the character before it`() { + val mentions = detectMentions("ask @jeff.", emptyList()) + assertEquals(4 to 9, mentions.first().let { it.start to it.end }) + } + + @Test + fun `uppercase letters fold to the stored lowercase handle`() { + assertEquals(listOf("jeff_99"), handles("hey @Jeff_99")) + } + + @Test + fun `every handle in a message is found, in order`() { + assertEquals(listOf("ab", "cd", "ef"), handles("@ab and @cd,@ef")) + } + + @Test + fun `trailing punctuation ends the handle`() { + assertEquals(listOf("jeff"), handles("thanks @jeff!")) + assertEquals(listOf("jeff"), handles("(@jeff)")) + } + + @Test + fun `an email address is not a mention`() { + assertEquals(emptyList(), handles("write me@example.com")) + } + + @Test + fun `a doubled at sign is not a mention`() { + assertEquals(emptyList(), handles("@@jeff")) + assertEquals(emptyList(), handles("@jeff@")) + } + + @Test + fun `handles outside 2 to 15 characters are not mentions`() { + assertEquals(emptyList(), handles("@a")) + assertEquals(emptyList(), handles("@abcdefghijklmnop")) + assertEquals(listOf("abcdefghijklmno"), handles("@abcdefghijklmno")) + } + + @Test + fun `characters a handle cannot hold end it rather than extend it`() { + assertEquals(listOf("jeff"), handles("@jeff-smith")) + assertEquals(listOf("je"), handles("@je.ff")) + } + + @Test + fun `a handle inside a web link is left to the link`() { + val text = "see x.com/@jeff" + val links = detectUrls(text) + assertTrue(links.isNotEmpty()) + assertEquals(emptyList(), handles(text, links)) + } + + @Test + fun `a handle beside a web link is still a mention`() { + val text = "@jeff https://apple.com" + assertEquals(listOf("jeff"), handles(text, detectUrls(text))) + } + + @Test + fun `ranges are UTF-16 offsets, past an emoji`() { + val mentions = detectMentions("🎉 @jeff", emptyList()) + assertEquals(3 to 8, mentions.first().let { it.start to it.end }) + } + + @Test + fun `plain text has no mentions`() { + assertEquals(emptyList(), handles("no handles here")) + assertEquals(emptyList(), handles("just an @ sign")) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt new file mode 100644 index 0000000000..ae8c788fae --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt @@ -0,0 +1,66 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** How a text bubble's body is laid out around its mentions. Robolectric for [detectUrls]. */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class MentionedTextTest { + + private fun build(text: String, onClick: ((String) -> Unit)? = {}) = + buildMentionedText(text, detectMentions(text, detectUrls(text)), SpanStyle(), onClick) + + @Test + fun `each handle sits between two spacing placeholders`() { + val built = build("hi @jeff there") + + // "hi " + placeholder, then "@jeff", then placeholder + " there". + assertEquals(listOf(TextRange(4, 9)), built.handles) + assertEquals("@jeff", built.text.text.substring(4, 9)) + assertEquals(2, built.text.getStringAnnotations(0, built.text.length).count { it.item == MENTION_SPACING_SLOT }) + } + + @Test + fun `the tap covers the pill's spacing as well as the letters`() { + var tapped: String? = null + val built = build("hi @Jeff", onClick = { tapped = it }) + + val link = built.text.getLinkAnnotations(0, built.text.length).single() + assertEquals(3 to 10, link.start to link.end) + (link.item as LinkAnnotation.Clickable).linkInteractionListener!!.onClick(link.item) + assertEquals("jeff", tapped) + } + + @Test + fun `a link after a mention moves by the mention's placeholders`() { + val built = build("@jeff see https://example.com") + + val url = built.text.getLinkAnnotations(0, built.text.length) + .single { it.item is LinkAnnotation.Url } + assertEquals("https://example.com", built.text.text.substring(url.start, url.end)) + } + + @Test + fun `nothing is tappable where mentions cannot be opened, but the pill is still drawn`() { + val built = build("hi @jeff", onClick = null) + + assertTrue(built.text.getLinkAnnotations(0, built.text.length).isEmpty()) + assertEquals(1, built.handles.size) + } + + @Test + fun `text with no mention is unchanged`() { + val built = build("hello there") + + assertEquals("hello there", built.text.text) + assertTrue(built.handles.isEmpty()) + } +} From a4c68638ff8dd3905050214f6b21efd535a3458d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 18:09:44 -0400 Subject: [PATCH 2/2] 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. --- .../flipcash/shared/chat/ui/MentionPills.kt | 10 ++- .../flipcash/shared/chat/ui/MessageBubble.kt | 10 ++- .../com/flipcash/shared/chat/ui/WidestLine.kt | 64 +++++++++++++++++++ .../shared/chat/ui/MentionedTextTest.kt | 13 +++- 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WidestLine.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt index c29c9a07e0..9e32ad5ee7 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt @@ -64,6 +64,7 @@ internal fun buildMentionedText( text: String, mentions: List, linkStyle: SpanStyle, + mentionStyle: SpanStyle, onMentionClick: ((String) -> Unit)?, ): MentionedText { val ordered = mentions.sortedBy { it.start } @@ -81,9 +82,14 @@ internal fun buildMentionedText( handles += TextRange(handleStart, length) appendInlineContent(MENTION_SPACING_SLOT, MENTION_SPACING_STAND_IN) // Over the spacing as well as the letters, so the whole pill takes the tap. - if (onMentionClick != null) { + if (onMentionClick == null) { + addStyle(mentionStyle, handleStart, length) + } else { addLink( - LinkAnnotation.Clickable(tag = mention.username) { onMentionClick(mention.username) }, + LinkAnnotation.Clickable( + tag = mention.username, + styles = TextLinkStyles(style = mentionStyle), + ) { onMentionClick(mention.username) }, start = tapStart, end = length, ) 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 b2434af2e1..9134f3e42c 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 @@ -410,6 +410,12 @@ private fun TextBubble( color = CodeTheme.colors.textMain, textDecoration = TextDecoration.Underline, ) + // The pill is what marks a mention, so its letters stay the body's own. A tappable span + // with no style of its own is drawn as a web link, underlined in the platform's colour. + val mentionStyle = SpanStyle( + color = CodeTheme.colors.textMain, + textDecoration = TextDecoration.None, + ) // Read through the latest handler, so a recomposition that hands over a new lambda does // not rebuild the body; only whether mentions can be tapped at all is part of its key. val currentOnMentionClick by rememberUpdatedState(onMentionClick) @@ -418,11 +424,12 @@ private fun TextBubble( val mentioned = if (isTombstone) { null } else { - remember(bodyString, mentions, linkStyle, mentionsTappable) { + remember(bodyString, mentions, linkStyle, mentionStyle, mentionsTappable) { buildMentionedText( text = bodyString, mentions = mentions, linkStyle = linkStyle, + mentionStyle = mentionStyle, onMentionClick = if (mentionsTappable) { { username -> currentOnMentionClick?.invoke(username) } } else { @@ -497,6 +504,7 @@ private fun TextBubble( Text( modifier = Modifier .padding(horizontal = bodyInset) + .widestLine(measurer, laidOut, bodyStyle, inlineContent) .then( if (handles.isEmpty()) { Modifier diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WidestLine.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WidestLine.kt new file mode 100644 index 0000000000..99c7f56773 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/WidestLine.kt @@ -0,0 +1,64 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import kotlin.math.ceil + +/** + * Narrows wrapped text to its widest line. + * + * Text that wraps reports the whole width it was offered, not the width of its lines, so a bubble + * around it stretches to its ceiling and leaves the slack of the wrapped word on the right. A long + * word or a mention's pill wrapping onto its own line makes that slack most of the bubble. SwiftUI + * sizes text to its widest line, which is what the iOS bubble shows. + * + * The text is measured once here at the offered width to find that line, then handed a ceiling of + * exactly its width. [style] and [inlineContent] have to be what the text itself is drawn with, or + * the two measurements break lines differently. + */ +internal fun Modifier.widestLine( + measurer: TextMeasurer, + text: AnnotatedString, + style: TextStyle, + inlineContent: Map, +): Modifier = layout { measurable, constraints -> + if (!constraints.hasBoundedWidth) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + val probe = measurer.measure( + text = text, + style = style, + placeholders = text.placeholders(inlineContent), + constraints = Constraints(maxWidth = constraints.maxWidth), + layoutDirection = layoutDirection, + density = this, + ) + val narrowed = if (probe.lineCount > 1) { + constraints.copy(maxWidth = probe.widestLine().coerceIn(constraints.minWidth, constraints.maxWidth)) + } else { + constraints + } + val placeable = measurable.measure(narrowed) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } +} + +internal fun TextLayoutResult.widestLine(): Int = + ceil((0 until lineCount).maxOf { getLineRight(it) - getLineLeft(it) }).toInt() + +// The tag `appendInlineContent` files its slots under. Foundation keeps the constant internal. +private const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineContent" + +private fun AnnotatedString.placeholders( + inlineContent: Map, +): List> = + getStringAnnotations(INLINE_CONTENT_TAG, 0, length).mapNotNull { slot -> + inlineContent[slot.item]?.let { AnnotatedString.Range(it.placeholder, slot.start, slot.end) } + } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt index ae8c788fae..c149e596d1 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt @@ -1,8 +1,10 @@ package com.flipcash.shared.chat.ui +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.style.TextDecoration import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -15,8 +17,10 @@ import kotlin.test.assertTrue @Config(manifest = Config.NONE) class MentionedTextTest { + private val mentionStyle = SpanStyle(color = Color.White, textDecoration = TextDecoration.None) + private fun build(text: String, onClick: ((String) -> Unit)? = {}) = - buildMentionedText(text, detectMentions(text, detectUrls(text)), SpanStyle(), onClick) + buildMentionedText(text, detectMentions(text, detectUrls(text)), SpanStyle(), mentionStyle, onClick) @Test fun `each handle sits between two spacing placeholders`() { @@ -39,6 +43,13 @@ class MentionedTextTest { assertEquals("jeff", tapped) } + @Test + fun `a tappable mention carries its own style, so it is not drawn as a web link`() { + val link = build("hi @jeff").text.getLinkAnnotations(0, 10).single() + + assertEquals(mentionStyle, (link.item as LinkAnnotation.Clickable).styles?.style) + } + @Test fun `a link after a mention moves by the mention's placeholders`() { val built = build("@jeff see https://example.com")