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/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 714c292788..97ddde6fa1 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..be81dc2a7b 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.error_title_usernameNotFound), + message = resources.getString(R.string.error_description_usernameNotFound, destination.username), + ) + MentionDestination.LookupFailed -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_profileUnavailable), + message = resources.getString(R.string.error_description_profileUnavailable), + ) + 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 7a93e4272b..cf15c2ab88 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 @@ -36,6 +37,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 @@ -50,6 +52,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) { @@ -71,6 +74,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 -> { @@ -159,6 +183,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 51d290363c..867ae64db6 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 @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 @@ -66,18 +67,24 @@ 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. Share shows on every person's profile. + * + * A tapped `@handle` naming someone other than a tip DM's counterpart opens with + * [ProfileOrigin.Mention]. That person is not who the chat is with, so the screen leaves the chat + * out, as a `flipcash.com` link's profile does: no Mute, and Message shows. */ @Composable internal fun ChatProfileScreen( viewModel: ChatProfileViewModel, chatViewModel: ChatViewModel, + origin: ProfileOrigin = ProfileOrigin.Chat, ) { val flowNavigator = rememberFlowNavigator() val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() PersonProfileScreen( viewModel = viewModel, - chat = ProfileChat( + chat = profileChat( + origin = origin, chatId = chatState.chatId, chatType = chatState.chatType, viewerState = chatState.viewerState, @@ -87,6 +94,20 @@ internal fun ChatProfileScreen( ) } +/** + * The chat behind a profile opened from inside it, or null for a mention, whose person the chat is + * not with. + */ +internal fun profileChat( + origin: ProfileOrigin, + chatId: ChatId?, + chatType: ChatType, + viewerState: ViewerState?, +): ProfileChat? = when (origin) { + ProfileOrigin.Chat -> ProfileChat(chatId, chatType, viewerState) + ProfileOrigin.Mention -> null +} + /** * The conversation a person's profile was opened from, for the parts of the screen that act on the * chat rather than the person: the mute row and the muted chip. 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..d0ce51cd08 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MentionDestinationTest.kt @@ -0,0 +1,90 @@ +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.profileChat +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.assertNull + +/** 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)) + val chat = profileChat(ProfileOrigin.Mention, chatId = null, ChatType.TIP_DM, viewerState = null) + assertNull(chat) + assertEquals(other, profileShortcutRecipient(other, chat?.chatType, selfId)) + } +} 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..e71f28a81e 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,6 +1,7 @@ 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 @@ -47,4 +48,24 @@ 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 and no Mute, since the chat is not with them`() { + val chat = profileChat(ProfileOrigin.Mention, chatId = null, ChatType.TIP_DM, viewerState = null) + assertNull(chat) + assertEquals(member, profileShortcutRecipient(member, chat?.chatType, self)) + } + + @Test + fun `a mention of the viewer still doesn't`() { + val me = member.copy(userId = self) + assertNull(profileShortcutRecipient(me, chatType = null, self)) + } + + @Test + fun `a profile opened from the chat keeps it, so a tip DM's counterpart offers Mute`() { + val chat = profileChat(ProfileOrigin.Chat, chatId = null, ChatType.TIP_DM, viewerState = null) + assertEquals(ChatType.TIP_DM, chat?.chatType) + assertNull(profileShortcutRecipient(member, chat?.chatType, self)) + } } 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..9e32ad5ee7 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionPills.kt @@ -0,0 +1,152 @@ +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, + mentionStyle: 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) { + addStyle(mentionStyle, handleStart, length) + } else { + addLink( + LinkAnnotation.Clickable( + tag = mention.username, + styles = TextLinkStyles(style = mentionStyle), + ) { 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..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 @@ -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,36 @@ 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) + 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, mentionStyle, mentionsTappable) { + buildMentionedText( + text = bodyString, + mentions = mentions, + linkStyle = linkStyle, + mentionStyle = mentionStyle, + 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 +475,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 +502,21 @@ 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) + .widestLine(measurer, laidOut, bodyStyle, inlineContent) + .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/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/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..c149e596d1 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MentionedTextTest.kt @@ -0,0 +1,77 @@ +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 +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 val mentionStyle = SpanStyle(color = Color.White, textDecoration = TextDecoration.None) + + private fun build(text: String, onClick: ((String) -> Unit)? = {}) = + buildMentionedText(text, detectMentions(text, detectUrls(text)), SpanStyle(), mentionStyle, 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 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") + + 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()) + } +}