From dd6c1143671188fae0bf7fa1f74d9b240e1321fe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 10:16:09 -0400 Subject: [PATCH 1/9] feat(messenger): suggest group members for an @-mention as the user types The composer's reply strip becomes one slot in an ordered accessory stack: mention suggestions, then the reply strip, then the input row. Both cards use the reply surface's enter and exit spring. The picker opens on the whitespace-free word ending at the cursor when it starts with "@" at the start of the text or after whitespace, so "a@b" never opens it. It searches RosterSearchSource with that word, cancelling the last search when the word changes, and lists only members with a username. It is for groups only and closes on whitespace, a moved cursor, a deleted "@", a send or a pick. A pick replaces the word with "@username " as plain text. The first page of the roster is refreshed once per screen visit, the first time the picker opens. The list shows up to 4 rows, 3 with a reply strip open, and 2 when that many would leave the transcript under 120dp. Row values come from the prototype; the visual pass against the design is still to do. --- .../app/messenger/internal/ChatViewModel.kt | 76 ++++ .../internal/mention/MentionQuery.kt | 71 ++++ .../internal/mention/MentionSuggestionList.kt | 126 ++++++ .../internal/screens/MessengerScreen.kt | 1 + .../screens/components/ChatBottomBar.kt | 396 +++++++++--------- .../screens/components/ComposerAccessories.kt | 112 +++++ .../internal/ChatGroupAnalyticsTest.kt | 1 + .../internal/ChatGroupCashLinkTest.kt | 1 + .../internal/ChatMentionPickerTest.kt | 241 +++++++++++ .../internal/ChatOpenTranscriptTest.kt | 1 + .../internal/ChatSendFailureAnalyticsTest.kt | 1 + .../internal/mention/MentionQueryTest.kt | 135 ++++++ .../shared/chat/RosterSearchSource.kt | 42 ++ 13 files changed, 1003 insertions(+), 201 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RosterSearchSource.kt 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 8a9c60ceb9..993af626b9 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 @@ -4,6 +4,7 @@ import android.content.ClipboardManager import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -45,6 +46,9 @@ import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.link.ClaimReplyTargets import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver +import com.flipcash.app.messenger.internal.mention.activeMentionToken +import com.flipcash.app.messenger.internal.mention.insertMention +import com.flipcash.app.messenger.internal.mention.mentionable import com.flipcash.app.persistence.sources.UserProfileDataSource import com.flipcash.app.session.CashLinkClaims import com.flipcash.app.session.ChatCashLinks @@ -86,7 +90,9 @@ import com.flipcash.shared.chat.SpeakerBlock import com.flipcash.shared.chat.speakerBlock import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.MessagePolicy +import com.flipcash.shared.chat.MemberMatch import com.flipcash.shared.chat.MessageReactions +import com.flipcash.shared.chat.RosterSearchSource import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.applying import com.flipcash.shared.chat.canReact @@ -166,6 +172,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -213,6 +220,7 @@ internal class ChatViewModel @Inject constructor( private val emojiCatalogLoader: EmojiCatalogLoader, private val toastController: SystemToastController, private val userProfileDataSource: UserProfileDataSource, + private val rosterSearch: RosterSearchSource, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -312,6 +320,14 @@ internal class ChatViewModel @Inject constructor( * emits, so a reducer that cleared it would empty it before the handler could read it. */ val replyingTo: ChatQuote? = null, + /** + * Members matching the `@` word at the composer's cursor, for the mention picker. Empty + * whenever the picker is closed: outside a group, with no `@` word at the cursor, or when + * nothing matched. Only members with a username, since the username is what a pick inserts. + * + * Independent of [replyingTo] and [editing]: opening or cancelling a reply leaves it alone. + */ + val mentionSuggestions: List = emptyList(), /** * A message the transcript has been asked to scroll to, held until the list consumes it. * @@ -692,6 +708,12 @@ internal class ChatViewModel @Inject constructor( data class ReplyToMessage(val quote: ChatQuote) : Event data object CancelReply : Event + /** The mention picker's results for the `@` word now at the cursor; empty closes it. */ + data class OnMentionSuggestions(val matches: List) : Event + + /** Replaces the `@` word at the cursor with [match]'s username and a space. */ + data class PickMention(val match: MemberMatch) : Event + /** * Toggles the viewer's reaction with [emoji] on [messageId] — a tap on a pill, on the * quick strip above a selected bubble, or a pick from the full picker. When it came from @@ -1272,6 +1294,7 @@ internal class ChatViewModel @Inject constructor( initTokenAndExchangeObservers() initTypingHandlers() initSendHandlers() + initMentionPicker() initMessageActionHandlers() } } @@ -2254,6 +2277,55 @@ internal class ChatViewModel @Inject constructor( dispatchEvent(Event.EditingEnded) } + /** + * Drives the mention picker from the composer's text and cursor. + * + * Each change works out the `@` word at the cursor and searches the roster for it; a new word + * cancels the search still running for the last one. No word (whitespace typed, the cursor + * moved off it, the `@` deleted, the text sent) closes the picker. Groups only, and only while + * the viewer has a composer to type in. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun initMentionPicker() { + // Once per visit, the first time the picker opens: profile edits don't move the roster + // version, so the names held since the last full read can be stale. + var refreshed = false + val eligible = stateFlow + .map { it.chatType == ChatType.GROUP && !it.isAnonymous && !it.replacesComposer } + .distinctUntilChanged() + val word = snapshotFlow { + val input = stateFlow.value.chatInputState + activeMentionToken(input.text, input.selection)?.text + } + combine(eligible, word) { canMention, query -> query.takeIf { canMention } } + .distinctUntilChanged() + .mapLatest { query -> + val chatId = stateFlow.value.chatId + if (query == null || chatId == null) return@mapLatest emptyList() + if (!refreshed) { + refreshed = true + viewModelScope.launch { runCatching { rosterSearch.refresh(chatId) } } + } + rosterSearch.search(chatId, query).mentionable() + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnMentionSuggestions(it)) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val username = event.match.username?.takeIf { it.isNotBlank() } ?: return@onEach + val input = stateFlow.value.chatInputState + val token = activeMentionToken(input.text, input.selection) ?: return@onEach + val (text, cursor) = insertMention(input.text.toString(), token, username) + input.edit { + replace(0, length, text) + selection = TextRange(cursor) + } + } + .launchIn(viewModelScope) + } + private fun initSendHandlers() { // Send text message eventFlow.filterIsInstance() @@ -2924,6 +2996,10 @@ internal class ChatViewModel @Inject constructor( ) } Event.CancelReply -> { state -> state.copy(replyingTo = null) } + is Event.OnMentionSuggestions -> { state -> state.copy(mentionSuggestions = event.matches) } + // Closed at once rather than waiting for the inserted space to reach the query + // observer, so the list doesn't linger a frame over the text it just wrote. + is Event.PickMention -> { state -> state.copy(mentionSuggestions = emptyList()) } // The toggle itself is handled in initMessageActionHandlers (it calls the // coordinator); the reducer's only job is to take the bubble out of selection mode // when the tap came from the quick strip. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt new file mode 100644 index 0000000000..d45ff33427 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt @@ -0,0 +1,71 @@ +package com.flipcash.app.messenger.internal.mention + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.shared.chat.MemberMatch + +/** + * The `@` word the cursor is in, from the `@` up to the cursor. + * + * [start] is the `@`'s index and [end] the cursor's. [text] is the whole run including the `@`, + * which is what goes to the search: it strips one leading `@` itself, so "@@eri" searches "@eri" + * and finds nobody, as it does on iOS. + */ +internal data class MentionToken(val start: Int, val end: Int, val text: String) + +/** Both the ASCII sign and the fullwidth one a CJK keyboard types; search folds them together. */ +private fun Char.isMentionSign(): Boolean = this == '@' || this == '@' + +/** + * The mention being typed at [selection] in [text], or null when there isn't one. + * + * A mention is the whitespace-free run ending at the cursor, and it has to start with an `@` that + * is at the start of the text or right after whitespace, so "a@b" is an email-shaped word and not + * a mention. A selection that covers text is not a cursor, so it never has one. + */ +internal fun activeMentionToken(text: CharSequence, selection: TextRange): MentionToken? { + if (!selection.collapsed) return null + val cursor = selection.end + if (cursor <= 0 || cursor > text.length) return null + var start = cursor + while (start > 0 && !text[start - 1].isWhitespace()) start-- + // Whitespace right before the cursor: no word to be in. + if (start == cursor || !text[start].isMentionSign()) return null + return MentionToken(start = start, end = cursor, text = text.substring(start, cursor)) +} + +/** + * [text] with [token] replaced by `@`[username] and one space, and the cursor after that space. + * + * Only the token moves: whatever came before the `@` and after the cursor is kept as it was. The + * space ends the word, so the picker closes on the same rule as typing one. + */ +internal fun insertMention(text: String, token: MentionToken, username: String): Pair { + val inserted = "@$username " + val result = text.substring(0, token.start) + inserted + text.substring(token.end) + return result to token.start + inserted.length +} + +/** Only members with a username can be mentioned: the username is what gets inserted. */ +internal fun List.mentionable(): List = filter { !it.username.isNullOrBlank() } + +/** The least room left for the transcript above the picker before it gives up rows for it. */ +internal val MentionMinTranscriptHeight = 120.dp + +/** + * How many rows the picker shows before it scrolls: 4, or 3 with a reply strip under it, and 2 + * when showing that many would leave less than [MentionMinTranscriptHeight] of transcript. + * + * [roomAboveComposer] is the height between the top bar and the rest of the composer (reply strip + * and input row), which the picker and the transcript share; [listHeight] is the picker's height + * at a given row count. + */ +internal fun mentionRowCap( + replyOpen: Boolean, + roomAboveComposer: Dp, + listHeight: (rows: Int) -> Dp, +): Int { + val cap = if (replyOpen) 3 else 4 + return if (roomAboveComposer - listHeight(cap) < MentionMinTranscriptHeight) 2 else cap +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt new file mode 100644 index 0000000000..7e65b64dbb --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt @@ -0,0 +1,126 @@ +package com.flipcash.app.messenger.internal.mention + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.chat.MemberMatch +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials + +/** + * Group members offered for an `@`-mention, in the reply strip's card: the same glass, hairline and + * shape. Rows are [MentionSuggestionListDefaults.rowHeight] tall; past [maxRows] the list scrolls. + * + * Values come from the prototype (layout only); the visual pass against the design is still to do. + */ +@Composable +internal fun MentionSuggestionList( + matches: List, + maxRows: Int, + hazeState: HazeState, + onSelect: (MemberMatch) -> Unit, + modifier: Modifier = Modifier, +) { + val shape = CodeTheme.shapes.medium + val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + val border = CodeTheme.dimens.border + + LazyColumn( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .hazeBlur(HazeInput.Sources(hazeState), material) + .border(border, CodeTheme.colors.divider, shape) + .heightIn(max = mentionListHeight(maxRows, border)), + ) { + itemsIndexed(matches, key = { _, match -> match.userId.toString() }) { index, match -> + if (index > 0) { + Box( + Modifier + .fillMaxWidth() + .height(border) + .background(CodeTheme.colors.divider), + ) + } + MentionRow(match = match, onClick = { onSelect(match) }) + } + } +} + +@Composable +private fun MentionRow(match: MemberMatch, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(MentionSuggestionListDefaults.rowHeight) + .clickable(onClick = onClick) + .padding(horizontal = MentionSuggestionListDefaults.horizontalPadding) + .testTag("composer_mention_row"), + horizontalArrangement = Arrangement.spacedBy(MentionSuggestionListDefaults.gap), + verticalAlignment = Alignment.CenterVertically, + ) { + ContactAvatar( + image = match.profilePicture, + displayName = match.displayName, + // The picture belongs to this member's profile, which is what authorizes re-minting it. + access = BlobAccessContext.profile(match.userId), + modifier = Modifier + .size(MentionSuggestionListDefaults.avatar) + .clip(CircleShape), + ) + Text( + text = match.displayName, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier.weight(1f), + text = "@${match.username}", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** The list's height at [rows] rows: the rows plus a hairline between each pair. */ +internal fun mentionListHeight(rows: Int, divider: Dp): Dp { + val n = rows.coerceAtLeast(1) + return MentionSuggestionListDefaults.rowHeight * n + divider * (n - 1) +} + +internal object MentionSuggestionListDefaults { + val rowHeight = 50.dp + val avatar = 32.dp + val horizontalPadding = 10.dp + val gap = 9.dp +} 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 7dbd925b2d..924bd31056 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 @@ -362,6 +362,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { hazeState = hazeState, onAction = chatActionHandler, dispatch = viewModel::dispatchEvent, + topBarHeight = barHeight, ) } if (behindBackdrop) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index c28ac3dd01..6b338f802a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -1,7 +1,6 @@ package com.flipcash.app.messenger.internal.screens.components import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -11,6 +10,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -48,18 +48,19 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.balanceRequirement +import com.flipcash.app.messenger.internal.mention.mentionListHeight +import com.flipcash.app.messenger.internal.mention.mentionRowCap import com.flipcash.app.messenger.internal.requiresStaff import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.services.models.chat.ChatRuleRequirement -import com.flipcash.shared.chat.ui.ChatAnimations -import com.flipcash.shared.chat.ui.ComposerReplyStrip import com.getcode.theme.CodeTheme import com.getcode.ui.components.chat.ChatInput import com.getcode.ui.components.chat.ChatInputSubmit @@ -98,6 +99,7 @@ internal fun UserControlBottomBar( hazeState: HazeState, onAction: ChatActionHandler, dispatch: (ChatViewModel.Event) -> Unit, + topBarHeight: Dp = 0.dp, ) { if (state.isAnonymous) { DeactivatedChatBottomBar() @@ -142,6 +144,7 @@ internal fun UserControlBottomBar( val keyboard = rememberKeyboardController() val focusRequester = remember { FocusRequester() } var buttonHeight by remember { mutableStateOf(0.dp) } + var mentionListHeight by remember { mutableStateOf(0.dp) } val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) LaunchedEffect(keyboard.visible) { @@ -150,212 +153,203 @@ internal fun UserControlBottomBar( } } - Column( - modifier = Modifier - .fillMaxWidth(), - ) { - // Compact at rest: with the keyboard down the composer sits narrower and a little lower, into - // the navigation bar's inset, and opens out to the normal margins as the keyboard comes up. - // Only once there is a composer; the full-width Send Cash button keeps its width. - // - // Tracks how far open the keyboard is rather than springing on a keyboard up/down flag, so - // the bar changes size in step with the keyboard's own motion: not ahead of it (a flag that - // flips as the hide starts), and not after it (one that waits for the hide to finish). The - // composer appearing at all (typingConstraints resolving a frame after the bar is revealed) - // doesn't move anything, since nothing here animates on its own. - val keyboardOpen = keyboardOpenFraction() - val hasComposer = state.typingConstraints.enabled - val compactInset = CodeTheme.dimens.grid.x6.coerceAtLeast(CodeTheme.dimens.inset) - val sideInset = if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset - val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.grid.x2, 0.dp, keyboardOpen) else 0.dp - // The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks - // into it once the keyboard has cleared it. - val restingRoom = WindowInsets.navigationBars.exclude(WindowInsets.ime) - Box { - // The transcript runs under the bar and dissolves into the background here, from the - // bar's top edge to the bottom of the screen (or the keyboard's top edge). The bar has no - // surface of its own and floats over it. - val fadeColor = CodeTheme.colors.background - Box( - modifier = Modifier - .fillMaxWidth() - .height(buttonHeight) - .align(Alignment.BottomCenter) - .drawWithGradient( - brush = { startY, endY -> composerFade(fadeColor, startY, endY) }, - startY = { 0f }, - ), - ) - AnimatedContent( - modifier = Modifier - .measured { buttonHeight = it.height } - // Reports itself shorter by the drop and lets the controls run past its bottom - // edge, so the bar sits that far into the navigation bar's inset and the - // transcript's bottom padding (measured from this height) follows it down. - .layout { measurable, constraints -> - val drop = restingDrop.roundToPx().coerceAtMost(restingRoom.getBottom(this)) - val placeable = measurable.measure(constraints) - layout(placeable.width, (placeable.height - drop).coerceAtLeast(0)) { - placeable.place(0, 0) - } - } - .padding(vertical = CodeTheme.dimens.grid.x3) - .navigationBarsPadding() - // typingConstraints.enabled starts false and only resolves a frame or two after - // open, once Room confirms whether the chat has a cash message. Rendering the - // default false layout first showed a full-width "Send $" button that then - // scaled down to the pill + input box. Hold the bar invisible (but measured, so - // the message list keeps correct padding) until resolved, then reveal the final - // layout directly — no visible full-width state, no resize. - // - // The chat kind starts UNKNOWN and, for tip DMs, SendCashButton would otherwise - // read a not-yet-resolved chat as a non-tip chat and show the white expanded pill - // before condensing — a visible flash. Wait until the kind is known (chatType is - // CONTACT_DM or TIP_DM) so the bar reveals already in its final presentation. - // chatType resolves from a local contact lookup, not the network profile, so this - // adds no perceptible delay; a tip chat whose identity never resolves flips to the - // deactivated bar instead, so this can't hide it forever. - .alpha( - if (state.typingConstraints.resolved && - state.chatType != ChatType.UNKNOWN - ) 1f else 0f - ), - targetState = state.typingConstraints.enabled, - // The layout only ever changes on the initial async resolution, which is hidden by - // the alpha gate above, so snap rather than crossfade. The SendCashButton's own - // color/label springs still animate the typing interaction. - transitionSpec = { - ContentTransform( - targetContentEnter = EnterTransition.None, - initialContentExit = ExitTransition.None, - sizeTransform = null, - ) - }, - ) { canType -> - Column(modifier = Modifier.fillMaxWidth()) { - // A banner, unlike an edit, which swaps the leading control instead. The two differ - // in what the user needs to see: an edit's subject is already in front of them as - // the composer's text, while a reply's subject is a different message that is very - // likely scrolled off screen. Gated on canType so a reply strip never sits above a - // bar with nothing to send from. - // - // The bar grows into the strip rather than the strip appearing over the bar, so - // the reveal is a height animation with the content clipped by the edge that is - // moving. `replyingTo` is held past the dismissal by AnimatedVisibility's own - // retention, so the quote is still there to fade out on the way down. - // - // Held one target past the state: cancelling clears `replyingTo` on the frame - // the collapse starts, and reading it directly would shrink an empty strip. It - // still follows a live change, so replying to a second message while the strip - // is up swaps the quote rather than keeping the first. - var lastQuote by remember { mutableStateOf(state.replyingTo) } - state.replyingTo?.let { lastQuote = it } - AnimatedVisibility( - visible = state.replyingTo != null && canType, - enter = ChatAnimations.replySurfaceEnter, - exit = ChatAnimations.replySurfaceExit, - ) { - lastQuote?.let { quote -> - ComposerReplyStrip( - quote = quote, - onDismiss = { dispatch(ChatViewModel.Event.CancelReply) }, - hazeState = hazeState, - modifier = Modifier - // Inset to the composer row's own margins, so the card's edges - // line up with the field it sits above, and narrows with it. - .padding(horizontal = sideInset) - .padding(bottom = CodeTheme.dimens.grid.x2) - .testTag("composer_reply_strip"), - ) + // The bar is measured against the whole screen (the scaffold overlays it), so this is the + // height the mention list's row cap weighs the transcript's share against. + BoxWithConstraints { + val screenHeight = maxHeight + Column( + modifier = Modifier + .fillMaxWidth(), + ) { + // Compact at rest: with the keyboard down the composer sits narrower and a little lower, into + // the navigation bar's inset, and opens out to the normal margins as the keyboard comes up. + // Only once there is a composer; the full-width Send Cash button keeps its width. + // + // Tracks how far open the keyboard is rather than springing on a keyboard up/down flag, so + // the bar changes size in step with the keyboard's own motion: not ahead of it (a flag that + // flips as the hide starts), and not after it (one that waits for the hide to finish). The + // composer appearing at all (typingConstraints resolving a frame after the bar is revealed) + // doesn't move anything, since nothing here animates on its own. + val keyboardOpen = keyboardOpenFraction() + val hasComposer = state.typingConstraints.enabled + val compactInset = CodeTheme.dimens.grid.x6.coerceAtLeast(CodeTheme.dimens.inset) + val sideInset = if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset + val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.grid.x2, 0.dp, keyboardOpen) else 0.dp + // The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks + // into it once the keyboard has cleared it. + val restingRoom = WindowInsets.navigationBars.exclude(WindowInsets.ime) + Box { + // The transcript runs under the bar and dissolves into the background here, from the + // bar's top edge to the bottom of the screen (or the keyboard's top edge). The bar has no + // surface of its own and floats over it. + val fadeColor = CodeTheme.colors.background + Box( + modifier = Modifier + .fillMaxWidth() + .height(buttonHeight) + .align(Alignment.BottomCenter) + .drawWithGradient( + brush = { startY, endY -> composerFade(fadeColor, startY, endY) }, + startY = { 0f }, + ), + ) + AnimatedContent( + modifier = Modifier + .measured { buttonHeight = it.height } + // Reports itself shorter by the drop and lets the controls run past its bottom + // edge, so the bar sits that far into the navigation bar's inset and the + // transcript's bottom padding (measured from this height) follows it down. + .layout { measurable, constraints -> + val drop = restingDrop.roundToPx().coerceAtMost(restingRoom.getBottom(this)) + val placeable = measurable.measure(constraints) + layout(placeable.width, (placeable.height - drop).coerceAtLeast(0)) { + placeable.place(0, 0) + } } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = sideInset), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - verticalAlignment = Alignment.Bottom, - ) { - // Editing swaps the leading control rather than adding a banner above the bar: - // send-cash is not reachable mid-edit anyway, and cancel is what the slot is - // for while the edit is open. - if (state.editing != null) { - CancelEditButton( - onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, - ) - } else { - SendCashButton( - state = state, - hazeState = hazeState, - hazeMaterial = material, - onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.OnSendCash) + .padding(vertical = CodeTheme.dimens.grid.x3) + .navigationBarsPadding() + // typingConstraints.enabled starts false and only resolves a frame or two after + // open, once Room confirms whether the chat has a cash message. Rendering the + // default false layout first showed a full-width "Send $" button that then + // scaled down to the pill + input box. Hold the bar invisible (but measured, so + // the message list keeps correct padding) until resolved, then reveal the final + // layout directly — no visible full-width state, no resize. + // + // The chat kind starts UNKNOWN and, for tip DMs, SendCashButton would otherwise + // read a not-yet-resolved chat as a non-tip chat and show the white expanded pill + // before condensing — a visible flash. Wait until the kind is known (chatType is + // CONTACT_DM or TIP_DM) so the bar reveals already in its final presentation. + // chatType resolves from a local contact lookup, not the network profile, so this + // adds no perceptible delay; a tip chat whose identity never resolves flips to the + // deactivated bar instead, so this can't hide it forever. + .alpha( + if (state.typingConstraints.resolved && + state.chatType != ChatType.UNKNOWN + ) 1f else 0f + ), + targetState = state.typingConstraints.enabled, + // The layout only ever changes on the initial async resolution, which is hidden by + // the alpha gate above, so snap rather than crossfade. The SendCashButton's own + // color/label springs still animate the typing interaction. + transitionSpec = { + ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + sizeTransform = null, + ) + }, + ) { canType -> + Column(modifier = Modifier.fillMaxWidth()) { + // Cards above the input row: mention suggestions, then the reply strip. A reply + // is a banner, unlike an edit, which swaps the leading control instead. The two + // differ in what the user needs to see: an edit's subject is already in front of + // them as the composer's text, while a reply's subject is a different message + // that is very likely scrolled off screen. + val accessories = composerAccessories(state, canType) + val divider = CodeTheme.dimens.border + val mentionRows = mentionRowCap( + replyOpen = accessories.any { it is ComposerAccessory.Reply }, + // What the transcript and the list share: the screen less the top bar and + // the rest of this bar (the reply strip, input row and their padding). + roomAboveComposer = screenHeight - topBarHeight - (buttonHeight - mentionListHeight), + listHeight = { rows -> mentionListHeight(rows, divider) }, + ) + ComposerAccessoryStack( + accessories = accessories, + sideInset = sideInset, + mentionRows = mentionRows, + hazeState = hazeState, + dispatch = dispatch, + mentionListModifier = Modifier.measured { mentionListHeight = it.height }, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = sideInset), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.Bottom, + ) { + // Editing swaps the leading control rather than adding a banner above the bar: + // send-cash is not reachable mid-edit anyway, and cancel is what the slot is + // for while the edit is open. + if (state.editing != null) { + CancelEditButton( + onClick = { dispatch(ChatViewModel.Event.CancelEdit) }, + ) + } else { + SendCashButton( + state = state, + hazeState = hazeState, + hazeMaterial = material, + onClick = { + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.OnSendCash) + } } - } - ) - } + ) + } - if (canType) { - ChatInput( - modifier = Modifier - .testTag("chat_message_input") - .weight(1f) - .border( - CodeTheme.dimens.border, - CodeTheme.colors.divider, - CodeTheme.shapes.medium, - ) - .hazeBlur(HazeInput.Sources(hazeState), material), - focusRequester = focusRequester, - hint = stringResource( - if (state.replyingTo != null) R.string.hint_chatReply - else R.string.hint_chatMessage - ), - state = state.chatInputState, - // One read of the edit state decides both the glyph and what the tap - // does, so the composer cannot show a checkmark and send a new message. - submit = if (state.editing != null) { - ChatInputSubmit.ConfirmEdit { - dispatch(ChatViewModel.Event.SubmitEdit) - keyboard.restartInput() - } - } else { - ChatInputSubmit.Send { - dispatch(ChatViewModel.Event.SendMessage) - keyboard.restartInput() - } - }, - ) + if (canType) { + ChatInput( + modifier = Modifier + .testTag("chat_message_input") + .weight(1f) + .border( + CodeTheme.dimens.border, + CodeTheme.colors.divider, + CodeTheme.shapes.medium, + ) + .hazeBlur(HazeInput.Sources(hazeState), material), + focusRequester = focusRequester, + hint = stringResource( + if (state.replyingTo != null) R.string.hint_chatReply + else R.string.hint_chatMessage + ), + state = state.chatInputState, + // One read of the edit state decides both the glyph and what the tap + // does, so the composer cannot show a checkmark and send a new message. + submit = if (state.editing != null) { + ChatInputSubmit.ConfirmEdit { + dispatch(ChatViewModel.Event.SubmitEdit) + keyboard.restartInput() + } + } else { + ChatInputSubmit.Send { + dispatch(ChatViewModel.Event.SendMessage) + keyboard.restartInput() + } + }, + ) - // An edit starts from a long-press, which leaves the keyboard down, so the - // composer has to claim focus itself or the pre-filled text sits unreachable. - LaunchedEffect(state.editing?.messageId) { - if (state.editing != null) { - focusRequester.requestFocus() - keyboard.show() + // An edit starts from a long-press, which leaves the keyboard down, so the + // composer has to claim focus itself or the pre-filled text sits unreachable. + LaunchedEffect(state.editing?.messageId) { + if (state.editing != null) { + focusRequester.requestFocus() + keyboard.show() + } } - } - // A reply starts from a long-press or a swipe, neither of which raises the - // keyboard, so the composer claims focus for the same reason. - LaunchedEffect(state.replyingTo?.messageId) { - if (state.replyingTo != null) { - focusRequester.requestFocus() - keyboard.show() + // A reply starts from a long-press or a swipe, neither of which raises the + // keyboard, so the composer claims focus for the same reason. + LaunchedEffect(state.replyingTo?.messageId) { + if (state.replyingTo != null) { + focusRequester.requestFocus() + keyboard.show() + } } - } - // Restores the pre-#1075 behavior: when OnStartMessageInput raises - // state.messageInputRequested (returning from amount entry after a send, or a - // post-tip open), focus the input and show the keyboard. Co-located with - // ChatInput so focusRequester is guaranteed attached; consumes the request so - // it fires once and a later manual dismiss doesn't re-open it. - LaunchedEffect(state.messageInputRequested) { - if (state.messageInputRequested) { - focusRequester.requestFocus() - keyboard.show() - dispatch(ChatViewModel.Event.OnMessageInputConsumed) + // Restores the pre-#1075 behavior: when OnStartMessageInput raises + // state.messageInputRequested (returning from amount entry after a send, or a + // post-tip open), focus the input and show the keyboard. Co-located with + // ChatInput so focusRequester is guaranteed attached; consumes the request so + // it fires once and a later manual dismiss doesn't re-open it. + LaunchedEffect(state.messageInputRequested) { + if (state.messageInputRequested) { + focusRequester.requestFocus() + keyboard.show() + dispatch(ChatViewModel.Event.OnMessageInputConsumed) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt new file mode 100644 index 0000000000..0c888eb3f4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt @@ -0,0 +1,112 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.mention.MentionSuggestionList +import com.flipcash.shared.chat.MemberMatch +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.ui.ChatAnimations +import com.flipcash.shared.chat.ui.ComposerReplyStrip +import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.HazeState + +/** + * A card stacked above the composer's input row. + * + * The stack's order is fixed, top to bottom: mention suggestions, then the reply strip, then the + * input row. Each kind appears at most once; [composerAccessories] decides which are up. + */ +internal sealed interface ComposerAccessory { + data class MentionSuggestions(val matches: List) : ComposerAccessory + data class Reply(val quote: ChatQuote) : ComposerAccessory +} + +/** + * The accessories [state] puts above the composer, in stacking order. + * + * Nothing when [canType] is false, so no card sits over a bar with nothing to send from. The two + * are independent: a reply opening or closing leaves an open mention list where it is. + */ +internal fun composerAccessories(state: ChatViewModel.State, canType: Boolean): List { + if (!canType) return emptyList() + return buildList { + if (state.mentionSuggestions.isNotEmpty()) add(ComposerAccessory.MentionSuggestions(state.mentionSuggestions)) + state.replyingTo?.let { add(ComposerAccessory.Reply(it)) } + } +} + +/** + * Draws [accessories] above the input row, each growing in and out with the reply surface's + * spring. + * + * The bar grows into each card rather than the card appearing over the bar, so a reveal is a + * height animation with the content clipped by the moving edge. Each kind keeps its last value + * while it leaves: the state drops it on the frame the collapse starts, and reading it directly + * would shrink an empty card. A live change still swaps it, so replying to a second message while + * the strip is up changes the quote rather than keeping the first. + */ +@Composable +internal fun ComposerAccessoryStack( + accessories: List, + sideInset: Dp, + mentionRows: Int, + hazeState: HazeState, + dispatch: (ChatViewModel.Event) -> Unit, + mentionListModifier: Modifier = Modifier, +) { + val mentions = accessories.firstNotNullOfOrNull { it as? ComposerAccessory.MentionSuggestions } + val reply = accessories.firstNotNullOfOrNull { it as? ComposerAccessory.Reply } + + Column { + var lastMatches by remember { mutableStateOf(mentions?.matches.orEmpty()) } + mentions?.let { lastMatches = it.matches } + AnimatedVisibility( + visible = mentions != null, + enter = ChatAnimations.replySurfaceEnter, + exit = ChatAnimations.replySurfaceExit, + ) { + MentionSuggestionList( + matches = lastMatches, + maxRows = mentionRows, + hazeState = hazeState, + onSelect = { dispatch(ChatViewModel.Event.PickMention(it)) }, + modifier = mentionListModifier + .padding(horizontal = sideInset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .testTag("composer_mention_list"), + ) + } + + var lastQuote by remember { mutableStateOf(reply?.quote) } + reply?.let { lastQuote = it.quote } + AnimatedVisibility( + visible = reply != null, + enter = ChatAnimations.replySurfaceEnter, + exit = ChatAnimations.replySurfaceExit, + ) { + lastQuote?.let { quote -> + ComposerReplyStrip( + quote = quote, + onDismiss = { dispatch(ChatViewModel.Event.CancelReply) }, + hazeState = hazeState, + modifier = Modifier + // Inset to the composer row's own margins, so the card's edges line up + // with the field it sits above, and narrows with it. + .padding(horizontal = sideInset) + .padding(bottom = CodeTheme.dimens.grid.x2) + .testTag("composer_reply_strip"), + ) + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 5b5448fc3d..f09f482c9b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -154,6 +154,7 @@ class ChatGroupAnalyticsTest { toastController = mockk(relaxed = true), emojiCatalogLoader = emojiCatalogLoader, userProfileDataSource = userProfileDataSource, + rosterSearch = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index ddc8d35de2..298d75c44d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -158,6 +158,7 @@ class ChatGroupCashLinkTest { emojiCatalogLoader = mockk(relaxed = true), toastController = mockk(relaxed = true), userProfileDataSource = mockk(relaxed = true), + rosterSearch = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt new file mode 100644 index 0000000000..b7ade48eed --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -0,0 +1,241 @@ +package com.flipcash.app.messenger.internal + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.snapshots.Snapshot +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatMembership +import com.flipcash.shared.chat.MemberMatch +import com.flipcash.shared.chat.RosterSearchSource +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.flipcash.app.tokens.TokenCoordinator +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.flipcash.shared.payments.TipPaymentDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID +import kotlin.test.assertEquals + +/** + * The mention picker's behaviour in the view model: what opens it, what closes it, and what a pick + * and a send do to the composer's text. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatMentionPickerTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val tipPaymentDelegate = mockk(relaxed = true) + private val rosterSearch = mockk(relaxed = true) + + private val chatId = ChatId(UUID.randomUUID().bytes) + private val erica = match("Érica", "erica") + private val eric = match("Eric", null) + + @Before + fun setUp() { + every { userManager.accountCluster } returns mockk(relaxed = true) + every { exchange.preferredRate } returns Rate.oneToOne + every { transactionController.limits } returns MutableStateFlow(null) + every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) + every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap()) + every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException()) + coEvery { rosterSearch.search(any(), any(), any()) } returns listOf(erica, eric) + coEvery { chatCoordinator.sendMessage(any(), any(), any()) } returns Result.success(mockk(relaxed = true)) + } + + private fun createViewModel(): ChatViewModel = ChatViewModel( + chatCoordinator = chatCoordinator, + e2eePolicy = E2eePolicy(), + contactCoordinator = mockk(relaxed = true), + contactPaymentDelegate = mockk(relaxed = true), + tipPaymentDelegate = tipPaymentDelegate, + transactionController = transactionController, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + verifiedFiatCalculator = mockk(relaxed = true), + purchaseMethodController = mockk(relaxed = true), + userManager = userManager, + resources = mockk(relaxed = true), + analytics = RecordingAnalytics(), + clipboardManager = mockk(relaxed = true), + userFlags = mockk(relaxed = true), + linkCardClassifier = mockk(relaxed = true), + linkCardResolver = mockk(relaxed = true), + cashLinkClaims = mockk(relaxed = true), + chatCashLinks = mockk(relaxed = true), + chatDraftStore = mockk(relaxed = true), + recentReactionsStore = mockk(relaxed = true), + toastController = mockk(relaxed = true), + emojiCatalogLoader = mockk(relaxed = true), + userProfileDataSource = mockk(relaxed = true), + rosterSearch = rosterSearch, + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + ) + + private fun ChatViewModel.openGroup() { + dispatchEvent(ChatViewModel.Event.ChatFound(chatId)) + dispatchEvent( + ChatViewModel.Event.OnGroupResolved( + ChatMembership( + metadata = mockk(relaxed = true) { + every { this@mockk.chatId } returns this@ChatMentionPickerTest.chatId + every { rules } returns null + every { rosterSummary.memberCount } returns 4L + }, + isMember = true, + ), + ), + ) + } + + private fun TestScope.type(vm: ChatViewModel, text: String) { + vm.stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd(text) + Snapshot.sendApplyNotifications() + advanceUntilIdle() + } + + private val ChatViewModel.suggestions get() = stateFlow.value.mentionSuggestions + private val ChatViewModel.text get() = stateFlow.value.chatInputState.text.toString() + + @Test + fun `an at word in a group lists members with a username`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup() + type(vm, "hi @er") + + assertEquals(listOf(erica), vm.suggestions) + coVerify { rosterSearch.search(chatId, "@er", any()) } + } + + @Test + fun `outside a group nothing is searched`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.dispatchEvent(ChatViewModel.Event.ChatFound(chatId)) + type(vm, "hi @er") + + assertEquals(emptyList(), vm.suggestions) + coVerify(exactly = 0) { rosterSearch.search(any(), any(), any()) } + } + + @Test + fun `typing a space closes the list`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup() + type(vm, "hi @er") + type(vm, "hi @er ") + + assertEquals(emptyList(), vm.suggestions) + } + + @Test + fun `no matches hides the list`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { rosterSearch.search(any(), any(), any()) } returns listOf(eric) + val vm = createViewModel() + vm.openGroup() + type(vm, "@er") + + assertEquals(emptyList(), vm.suggestions) + } + + @Test + fun `picking a member writes their username and a space, and closes the list`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup() + type(vm, "thanks @er") + + vm.dispatchEvent(ChatViewModel.Event.PickMention(erica)) + Snapshot.sendApplyNotifications() + advanceUntilIdle() + + assertEquals("thanks @erica ", vm.text) + assertEquals(emptyList(), vm.suggestions) + } + + @Test + fun `cancelling a reply leaves the list open`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup() + type(vm, "@er") + val quote = ChatQuote(messageId = 1, authorName = "Ada", snippet = ChatQuoteSnippet.Text("hi"), accent = null, nameAccent = null) + + vm.dispatchEvent(ChatViewModel.Event.ReplyToMessage(quote)) + advanceUntilIdle() + assertEquals(listOf(erica), vm.suggestions) + + vm.dispatchEvent(ChatViewModel.Event.CancelReply) + advanceUntilIdle() + assertEquals(null, vm.stateFlow.value.replyingTo) + assertEquals(listOf(erica), vm.suggestions) + } + + @Test + fun `sending with the list open sends the text as typed`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup() + type(vm, "hi @er") + + vm.dispatchEvent(ChatViewModel.Event.SendMessage) + Snapshot.sendApplyNotifications() + advanceUntilIdle() + + coVerify { chatCoordinator.sendMessage(chatId, "hi @er", null) } + assertEquals(emptyList(), vm.suggestions) + } + + @Test + fun `the first page is refreshed once per visit`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + vm.openGroup() + type(vm, "@e") + type(vm, "@er") + type(vm, "@er ") + type(vm, "@er @x") + + coVerify(exactly = 1) { rosterSearch.refresh(chatId) } + } + + private fun match(name: String, username: String?) = MemberMatch( + userId = name.encodeToByteArray().toList(), + displayName = name, + username = username, + profilePicture = null, + ) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index bf035ec623..1509cbf9f2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -105,6 +105,7 @@ class ChatOpenTranscriptTest { toastController = mockk(relaxed = true), emojiCatalogLoader = mockk(relaxed = true), userProfileDataSource = mockk(relaxed = true), + rosterSearch = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 60cd9a9c3b..60d5a32d54 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -146,6 +146,7 @@ class ChatSendFailureAnalyticsTest { toastController = mockk(relaxed = true), emojiCatalogLoader = emojiCatalogLoader, userProfileDataSource = userProfileDataSource, + rosterSearch = mockk(relaxed = true), dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt new file mode 100644 index 0000000000..2ea8a5fc60 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt @@ -0,0 +1,135 @@ +package com.flipcash.app.messenger.internal.mention + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.components.ComposerAccessory +import com.flipcash.app.messenger.internal.screens.components.composerAccessories +import com.flipcash.shared.chat.MemberMatch +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MentionQueryTest { + + private fun token(text: String, cursor: Int = text.length) = activeMentionToken(text, TextRange(cursor)) + + @Test + fun `an at sign at the start of the text opens a mention`() { + assertEquals(MentionToken(0, 4, "@eri"), token("@eri")) + } + + @Test + fun `a bare at sign opens a mention with an empty query`() { + assertEquals(MentionToken(0, 1, "@"), token("@")) + } + + @Test + fun `an at sign after whitespace opens a mention`() { + assertEquals(MentionToken(3, 7, "@eri"), token("hi @eri")) + assertEquals(MentionToken(3, 7, "@eri"), token("hi\n@eri")) + } + + @Test + fun `an at sign inside a word does not`() { + assertNull(token("a@b")) + } + + @Test + fun `whitespace after the word closes it`() { + assertNull(token("hi @eri ")) + } + + @Test + fun `a cursor moved out of the word closes it`() { + // Back before the at sign, and into the next word. + assertNull(token("hi @eri there", cursor = 2)) + assertNull(token("hi @eri there", cursor = 13)) + } + + @Test + fun `the word runs only to the cursor`() { + assertEquals(MentionToken(0, 3, "@er"), token("@erica", cursor = 3)) + } + + @Test + fun `deleting the at sign closes it`() { + assertNull(token("hi eri")) + } + + @Test + fun `a selection is not a cursor`() { + assertNull(activeMentionToken("@eri", TextRange(1, 4))) + } + + @Test + fun `the fullwidth at sign opens a mention too`() { + assertEquals(MentionToken(0, 4, "@eri"), token("@eri")) + } + + @Test + fun `a doubled at sign keeps the second for the search to reject`() { + // The search strips exactly one, so "@@eri" searches "@eri" and finds nobody. + assertEquals("@@eri", token("@@eri")?.text) + } + + @Test + fun `picking replaces exactly the word and adds one space`() { + val text = "hey @er, see this" + val t = token(text, cursor = 7)!! + val (result, cursor) = insertMention(text, t, "erica") + assertEquals("hey @erica , see this", result) + assertEquals("hey @erica ".length, cursor) + } + + @Test + fun `picking at the end of the text leaves the cursor after the space`() { + val text = "thanks @e" + val (result, cursor) = insertMention(text, token(text)!!, "erica") + assertEquals("thanks @erica ", result) + assertEquals(result.length, cursor) + } + + @Test + fun `members without a username are not mentionable`() { + val erica = match("Érica", "erica") + val noHandle = match("Eric", null) + val blank = match("Erin", "") + assertEquals(listOf(erica), listOf(erica, noHandle, blank).mentionable()) + } + + @Test + fun `the row cap is 4 with no reply, 3 with one, and 2 when the transcript would be squeezed`() { + val height = { rows: Int -> 50.dp * rows } + val roomy = 800.dp + assertEquals(4, mentionRowCap(replyOpen = false, roomAboveComposer = roomy, listHeight = height)) + assertEquals(3, mentionRowCap(replyOpen = true, roomAboveComposer = roomy, listHeight = height)) + // 4 rows (200dp) in 300dp leaves 100dp, under the 120dp floor. + assertEquals(2, mentionRowCap(replyOpen = false, roomAboveComposer = 300.dp, listHeight = height)) + // 3 rows (150dp) in 260dp leaves 110dp. + assertEquals(2, mentionRowCap(replyOpen = true, roomAboveComposer = 260.dp, listHeight = height)) + // Exactly the floor is enough. + assertEquals(4, mentionRowCap(replyOpen = false, roomAboveComposer = 320.dp, listHeight = height)) + } + + @Test + fun `accessories stack mentions above the reply strip`() { + val quote = ChatQuote(messageId = 1, authorName = "Ada", snippet = ChatQuoteSnippet.Text("hi"), accent = null, nameAccent = null) + val matches = listOf(match("Érica", "erica")) + val state = ChatViewModel.State(mentionSuggestions = matches, replyingTo = quote) + assertEquals( + listOf(ComposerAccessory.MentionSuggestions(matches), ComposerAccessory.Reply(quote)), + composerAccessories(state, canType = true), + ) + assertEquals(emptyList(), composerAccessories(state, canType = false)) + } + + private fun match(name: String, username: String?) = MemberMatch( + userId = name.encodeToByteArray().toList(), + displayName = name, + username = username, + profilePicture = null, + ) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RosterSearchSource.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RosterSearchSource.kt new file mode 100644 index 0000000000..130cd6894d --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RosterSearchSource.kt @@ -0,0 +1,42 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MediaItem +import com.getcode.opencode.model.core.ID + +/** + * Finds members of a chat by what the user has typed after `@`. + * + * The seam between the mention picker and where members are searched. The only implementation + * today searches the server's mention pool; another source can be added behind this interface + * without its callers changing. + */ +interface RosterSearchSource { + + /** + * Up to [limit] members of [chatId] whose display name or handle has a word starting with each + * word of [query], ignoring case and diacritics. Never the current user. + * + * Empty when the members can't be read; the user can still type a handle by hand. + */ + suspend fun search(chatId: ChatId, query: String, limit: Int = DEFAULT_LIMIT): List + + /** + * Begins a composing session for [chatId], bringing what its searches read up to date. Called + * the first time the picker opens in a screen visit. + */ + suspend fun refresh(chatId: ChatId) + + companion object { + const val DEFAULT_LIMIT = 20 + } +} + +/** A member a search found. */ +data class MemberMatch( + val userId: ID, + val displayName: String, + // Bare, without the `@`. Null when the member has not claimed one. + val username: String?, + val profilePicture: MediaItem?, +) From d9e534d6fe327b085866ada1e6c2ed1711d56659 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 15:45:26 -0400 Subject: [PATCH 2/9] fix(messenger): search the open @ word again once the suggestion pool loads The first-page roster refresh runs in the background the first time the picker opens, so its results reached the list only on the next keystroke. When it succeeds, the open word now searches again, so a member who joined since the last read shows up without more typing. A refresh that lands after the list closed leaves it closed. --- .../app/messenger/internal/ChatViewModel.kt | 15 ++++++-- .../internal/ChatMentionPickerTest.kt | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) 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 993af626b9..675cd3bac3 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 @@ -179,6 +179,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -2290,6 +2291,9 @@ internal class ChatViewModel @Inject constructor( // Once per visit, the first time the picker opens: profile edits don't move the roster // version, so the names held since the last full read can be stale. var refreshed = false + // Bumped when that refresh lands, so the open word searches again and new joiners show + // without another keystroke. + val refreshes = MutableStateFlow(0) val eligible = stateFlow .map { it.chatType == ChatType.GROUP && !it.isAnonymous && !it.replacesComposer } .distinctUntilChanged() @@ -2297,14 +2301,19 @@ internal class ChatViewModel @Inject constructor( val input = stateFlow.value.chatInputState activeMentionToken(input.text, input.selection)?.text } - combine(eligible, word) { canMention, query -> query.takeIf { canMention } } + combine(eligible, word, refreshes) { canMention, query, refresh -> + query.takeIf { canMention } to refresh + } .distinctUntilChanged() - .mapLatest { query -> + .mapLatest { (query, _) -> val chatId = stateFlow.value.chatId if (query == null || chatId == null) return@mapLatest emptyList() if (!refreshed) { refreshed = true - viewModelScope.launch { runCatching { rosterSearch.refresh(chatId) } } + viewModelScope.launch { + runCatching { rosterSearch.refresh(chatId) } + .onSuccess { refreshes.update { it + 1 } } + } } rosterSearch.search(chatId, query).mentionable() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index b7ade48eed..d1a5f62372 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -30,6 +30,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -232,6 +233,41 @@ class ChatMentionPickerTest { coVerify(exactly = 1) { rosterSearch.refresh(chatId) } } + @Test + fun `the open word searches again when the refresh lands`() = runTest(mainCoroutineRule.dispatcher) { + val refreshed = CompletableDeferred() + val erin = match("Erin", "erin") + coEvery { rosterSearch.refresh(chatId) } coAnswers { refreshed.await() } + val vm = createViewModel() + vm.openGroup() + type(vm, "@er") + assertEquals(listOf(erica), vm.suggestions) + + // The refresh brought in a new joiner; no keystroke follows. + coEvery { rosterSearch.search(any(), any(), any()) } returns listOf(erica, erin) + refreshed.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf(erica, erin), vm.suggestions) + coVerify(exactly = 2) { rosterSearch.search(chatId, "@er", any()) } + } + + @Test + fun `a refresh landing after the list closed leaves it closed`() = runTest(mainCoroutineRule.dispatcher) { + val refreshed = CompletableDeferred() + coEvery { rosterSearch.refresh(chatId) } coAnswers { refreshed.await() } + val vm = createViewModel() + vm.openGroup() + type(vm, "@er") + type(vm, "@er ") + + refreshed.complete(Unit) + advanceUntilIdle() + + assertEquals(emptyList(), vm.suggestions) + coVerify(exactly = 1) { rosterSearch.search(any(), any(), any()) } + } + private fun match(name: String, username: String?) = MemberMatch( userId = name.encodeToByteArray().toList(), displayName = name, From 50a53a5e8b85fab202d714153a5aea8eaf2a20b3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 15:52:55 -0400 Subject: [PATCH 3/9] feat(messenger): match the mention rows to the design Rows take the design's metrics (mention row set, node 10633:68; spec, node 10662:64911): 14dp side padding and a 10dp gap, dividers inset 56dp so they start under the name, the display name at 12/16 bold in the member's own colour from the palette the reply strip uses for a quoted author, and the username at 14/18 medium in #EBEBF5 at 60%. The card keeps the reply strip's radius and hairline so the two stacked cards match, and avatars without a picture keep the app's shared initials fallback. --- .../internal/mention/MentionSuggestionList.kt | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt index 7e65b64dbb..7a2de8e4ac 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt @@ -16,14 +16,18 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getcode.ui.utils.generateComplementaryColorPalette import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.shared.chat.MemberMatch import com.flipcash.shared.common.ui.ContactAvatar @@ -35,9 +39,10 @@ import dev.chrisbanes.haze.blur.materials.HazeMaterials /** * Group members offered for an `@`-mention, in the reply strip's card: the same glass, hairline and - * shape. Rows are [MentionSuggestionListDefaults.rowHeight] tall; past [maxRows] the list scrolls. + * shape, so the two stacked cards read as one set. Rows are [MentionSuggestionListDefaults.rowHeight] + * tall; past [maxRows] the list scrolls and the card stays that height. * - * Values come from the prototype (layout only); the visual pass against the design is still to do. + * Metrics follow the design (mention row set, node 10633:68; spec, node 10662:64911). */ @Composable internal fun MentionSuggestionList( @@ -61,8 +66,10 @@ internal fun MentionSuggestionList( ) { itemsIndexed(matches, key = { _, match -> match.userId.toString() }) { index, match -> if (index > 0) { + // Starts under the name, clear of the avatar column; none after the last row. Box( Modifier + .padding(start = MentionSuggestionListDefaults.dividerInset) .fillMaxWidth() .height(border) .background(CodeTheme.colors.divider), @@ -96,16 +103,17 @@ private fun MentionRow(match: MemberMatch, onClick: () -> Unit) { ) Text( text = match.displayName, - style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), - color = CodeTheme.colors.textMain, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold, lineHeight = 16.sp), + // The same per-person colour the reply strip gives a quoted author. + color = nameColor(match.userId) ?: CodeTheme.colors.textMain, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.weight(1f), text = "@${match.username}", - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, + style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium), + color = MentionSuggestionListDefaults.usernameColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -121,6 +129,16 @@ internal fun mentionListHeight(rows: Int, divider: Dp): Dp { internal object MentionSuggestionListDefaults { val rowHeight = 50.dp val avatar = 32.dp - val horizontalPadding = 10.dp - val gap = 9.dp + val horizontalPadding = 14.dp + val gap = 10.dp + + /** Lines a divider up with the name: the row's leading padding, the avatar and the gap after it. */ + val dividerInset = horizontalPadding + avatar + gap + + /** iOS's secondary label, #EBEBF5 at 60%. */ + val usernameColor = Color(0xFFEBEBF5).copy(alpha = 0.6f) } + +@Composable +private fun nameColor(userId: List): Color? = + remember(userId) { generateComplementaryColorPalette(userId)?.second } From 34a99be15856a685bc3c5a1ae4d2b170d75a1955 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 16:28:52 -0400 Subject: [PATCH 4/9] fix(messenger): reuse the whitespace after a picked mention Picking a member before an existing space or newline added a second space. The mention now takes no trailing space when whitespace already follows the word, and the cursor lands after that whitespace. At the end of the text or before any other character it still adds one. --- .../messenger/internal/mention/MentionQuery.kt | 8 ++++++-- .../internal/mention/MentionQueryTest.kt | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt index d45ff33427..fb6da91820 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt @@ -37,14 +37,18 @@ internal fun activeMentionToken(text: CharSequence, selection: TextRange): Menti /** * [text] with [token] replaced by `@`[username] and one space, and the cursor after that space. + * When whitespace already follows the word, no space is added and the cursor goes after that one. * * Only the token moves: whatever came before the `@` and after the cursor is kept as it was. The * space ends the word, so the picker closes on the same rule as typing one. */ internal fun insertMention(text: String, token: MentionToken, username: String): Pair { - val inserted = "@$username " + // Whitespace already after the word separates the mention; the cursor goes past it instead of + // doubling it. Anywhere else, the mention brings its own space. + val followedBySpace = text.getOrNull(token.end)?.isWhitespace() == true + val inserted = if (followedBySpace) "@$username" else "@$username " val result = text.substring(0, token.start) + inserted + text.substring(token.end) - return result to token.start + inserted.length + return result to token.start + inserted.length + if (followedBySpace) 1 else 0 } /** Only members with a username can be mentioned: the username is what gets inserted. */ diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt index 2ea8a5fc60..88643f9aed 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt @@ -75,6 +75,22 @@ class MentionQueryTest { assertEquals("@@eri", token("@@eri")?.text) } + @Test + fun `picking before an existing space reuses it and puts the cursor after it`() { + val text = "hi @ma there" + val (result, cursor) = insertMention(text, token(text, cursor = 6)!!, "maria") + assertEquals("hi @maria there", result) + assertEquals("hi @maria ".length, cursor) + } + + @Test + fun `picking before a newline reuses it and puts the cursor after it`() { + val text = "hi @ma\nthere" + val (result, cursor) = insertMention(text, token(text, cursor = 6)!!, "maria") + assertEquals("hi @maria\nthere", result) + assertEquals("hi @maria\n".length, cursor) + } + @Test fun `picking replaces exactly the word and adds one space`() { val text = "hey @er, see this" From e736495734e10bd25b0b532ed96b671e5110dfaa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 16:31:27 -0400 Subject: [PATCH 5/9] fix(messenger): give the mention card and reply strip the field's fill The input field paints ChatInputDefaults.ContainerColor over its haze; the mention card and reply strip had the haze alone, so on the Seeker they sampled rgb 25 against the field's 30. Both now paint the same constant over the same haze. --- .../app/messenger/internal/mention/MentionSuggestionList.kt | 3 +++ .../com/flipcash/shared/chat/ui/ComposerReplyStrip.kt | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt index 7a2de8e4ac..e5e7211d7f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt @@ -32,6 +32,7 @@ import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.shared.chat.MemberMatch import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.ChatInputDefaults import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur @@ -61,6 +62,8 @@ internal fun MentionSuggestionList( .fillMaxWidth() .clip(shape) .hazeBlur(HazeInput.Sources(hazeState), material) + // The input field's own fill over the same glass, so card and field read as one surface. + .background(ChatInputDefaults.ContainerColor) .border(border, CodeTheme.colors.divider, shape) .heightIn(max = mentionListHeight(maxRows, border)), ) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt index bb8d5ac61e..61472ca8a9 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt @@ -37,6 +37,7 @@ import com.flipcash.shared.chat.models.ChatQuoteSnippet import com.getcode.opencode.compose.LocalExchange import com.getcode.theme.CodeTheme import com.getcode.ui.components.PriceWithFlag +import com.getcode.ui.components.chat.ChatInputDefaults import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur @@ -76,7 +77,10 @@ fun ComposerReplyStrip( // sample. val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) val ground = if (hazeState != null) { - Modifier.hazeBlur(HazeInput.Sources(hazeState), material) + Modifier + .hazeBlur(HazeInput.Sources(hazeState), material) + // The input field's own fill over the same glass, so strip and field read as one surface. + .background(ChatInputDefaults.ContainerColor) } else { Modifier.background(Color.White.copy(alpha = 0.1f)) } From f8d7010fdf9a5909db574eabb2e272ec91d1809c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 13:37:03 -0400 Subject: [PATCH 6/9] feat(chat): suggest @mentions from the server's pool Wire Chat.GetMentionSuggestions through ChatApi, ChatService, ChatRepository and ChatController. ServerRosterSearchSource, the only RosterSearchSource, fetches the pool once per composing session, filters it locally with a word-prefix rule while keeping the server's order, and moves each new message's sender to the front from the event stream. A failed fetch (DENIED, NOT_FOUND, transport) returns an empty result with no fallback; the user can still type a handle by hand. --- .../app/messenger/internal/ChatViewModel.kt | 4 +- .../flipcash/shared/chat/inject/ChatModule.kt | 14 ++ .../chat/internal/MentionSuggestionPool.kt | 97 +++++++++++ .../shared/chat/internal/MentionText.kt | 40 +++++ .../chat/internal/ServerRosterSearchSource.kt | 59 +++++++ .../internal/delegates/EventStreamDelegate.kt | 3 + .../flipcash/shared/chat/MentionTextTest.kt | 25 +++ .../chat/ServerRosterSearchSourceTest.kt | 155 ++++++++++++++++++ .../services/controllers/ChatController.kt | 9 + .../services/inject/FlipcashModule.kt | 3 +- .../services/internal/network/api/ChatApi.kt | 16 ++ .../internal/network/services/ChatService.kt | 23 +++ .../repositories/InternalChatRepository.kt | 20 +++ .../com/flipcash/services/models/Errors.kt | 10 ++ .../services/models/chat/MentionSuggestion.kt | 17 ++ .../services/repository/ChatRepository.kt | 12 ++ .../controllers/ChatControllerTest.kt | 6 + 17 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/MentionSuggestionPool.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/MentionText.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ServerRosterSearchSource.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MentionTextTest.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt create mode 100644 services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MentionSuggestion.kt 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 675cd3bac3..b9e200fb2e 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 @@ -2288,8 +2288,8 @@ internal class ChatViewModel @Inject constructor( */ @OptIn(ExperimentalCoroutinesApi::class) private fun initMentionPicker() { - // Once per visit, the first time the picker opens: profile edits don't move the roster - // version, so the names held since the last full read can be stale. + // Once per visit, the first time the picker opens: that starts the composing session, which + // fetches the group's mention pool. var refreshed = false // Bumped when that refresh lands, so the open word searches again and new joiners show // without another keystroke. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 89c8682e77..7629e5be2c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -2,7 +2,11 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.RosterSearchSource import com.flipcash.shared.chat.internal.DmOutgoingEncryption +import com.flipcash.shared.chat.internal.MentionPoolUpdates +import com.flipcash.shared.chat.internal.MentionSuggestionPool +import com.flipcash.shared.chat.internal.ServerRosterSearchSource import com.flipcash.shared.chat.internal.OutgoingEncryption import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.RealChatDraftStore @@ -35,6 +39,16 @@ abstract class ChatModule { impl: DmOutgoingEncryption ): OutgoingEncryption + @Binds + internal abstract fun bindRosterSearchSource( + impl: ServerRosterSearchSource + ): RosterSearchSource + + @Binds + internal abstract fun bindMentionPoolUpdates( + impl: MentionSuggestionPool + ): MentionPoolUpdates + @Binds @IntoSet abstract fun bindSessionListener( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/MentionSuggestionPool.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/MentionSuggestionPool.kt new file mode 100644 index 0000000000..ad0b05f3b5 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/MentionSuggestionPool.kt @@ -0,0 +1,97 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MentionSuggestion +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The pool of people a group offers for `@` mentions, as `Chat.GetMentionSuggestions` ranked it. + * + * Fetched once per composing session: [startSession] replaces whatever is held with a new fetch, + * and [pool] waits on that fetch, starting one if no session has begun. Held in memory only; the + * next session fetches again, which is how senders the pool has never seen get in. + * + * Between fetches, [onMessages] keeps the order fresh by moving a known sender to the front. + */ +@Singleton +internal class MentionSuggestionPool @Inject constructor( + private val chatController: ChatController, + private val profiles: UserProfileDataSource, +) : MentionPoolUpdates { + + private val lock = Mutex() + private val sessions = mutableMapOf>>>() + + /** Begins a composing session for [chatId] with a fresh fetch, on [scope]. */ + suspend fun startSession(scope: CoroutineScope, chatId: ChatId) { + lock.withLock { sessions[chatId] = scope.fetchAsync(chatId) } + } + + /** [chatId]'s pool for the current session, most relevant first, or the failure that ended its fetch. */ + suspend fun pool(scope: CoroutineScope, chatId: ChatId): Result> = + lock.withLock { sessions.getOrPut(chatId) { scope.fetchAsync(chatId) } }.await() + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun onMessages(chatId: ChatId, senders: List>) { + lock.withLock { + val session = sessions[chatId] ?: return + if (!session.isCompleted) return + val held = session.getCompleted().getOrNull() ?: return + sessions[chatId] = CompletableDeferred(Result.success(movedToFront(held, senders))) + } + } + + private fun CoroutineScope.fetchAsync(chatId: ChatId): Deferred>> = async { + chatController.getMentionSuggestions(chatId).onSuccess { pool -> + pool.forEach { suggestion -> + val userId = suggestion.userProfile.userId ?: return@forEach + profiles.store(userId, suggestion.userProfile) + } + } + } + +} + +/** Told about each message the event stream delivers, so the mention pool's order stays fresh. */ +interface MentionPoolUpdates { + suspend fun onMessages(chatId: ChatId, senders: List>) + + object None : MentionPoolUpdates { + override suspend fun onMessages(chatId: ChatId, senders: List>) = Unit + } +} + +/** + * [pool] with each sender in [senders] that it already holds moved to the front, newest message + * first, when that message is newer than the sender's last_sent_at. A sender the pool doesn't hold + * is left out; the next session's fetch brings them in. + */ +internal fun movedToFront( + pool: List, + senders: List>, +): List { + val byUser = pool.associateBy { it.userProfile.userId } + val latest = senders.groupBy({ it.first }, { it.second }).mapValues { (_, times) -> times.max() } + val moved = latest + .mapNotNull { (userId, sentAt) -> + val held = byUser[userId] ?: return@mapNotNull null + val last = held.lastSentAt + if (last != null && last >= sentAt) null else held.copy(lastSentAt = sentAt) + } + .sortedByDescending { it.lastSentAt } + if (moved.isEmpty()) return pool + val movedIds = moved.map { it.userProfile.userId }.toSet() + return moved + pool.filterNot { it.userProfile.userId in movedIds } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/MentionText.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/MentionText.kt new file mode 100644 index 0000000000..3175795c87 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/MentionText.kt @@ -0,0 +1,40 @@ +package com.flipcash.shared.chat.internal + +import java.text.Normalizer +import java.util.Locale + +/** + * How member names and handles are matched against what the user typed after `@`. + * + * Both sides go through [normalize], so matching is case- and diacritic-insensitive: "eri" finds + * "Érica". iOS folds the same way, in the same order, so a query matches the same members on both + * platforms: + * + * 1. Unicode NFKD, which splits "É" into "E" plus a combining acute and folds compatibility forms + * such as full-width letters and ligatures. + * 2. Lowercase, locale-independent. After NFKD, not before: "İ" lowercases to "i" plus a combining + * dot, which step 3 then removes. + * 3. Drop every nonspacing combining mark (general category Mn). + */ +internal object MentionText { + + private val combiningMarks = Regex("\\p{Mn}+") + private val whitespace = Regex("[\\s\\p{Z}]+") + + fun normalize(text: String): String = + combiningMarks.replace(Normalizer.normalize(text, Normalizer.Form.NFKD).lowercase(Locale.ROOT), "") + + /** [text]'s normalized words, split on whitespace. */ + fun words(text: String): List = + normalize(text).split(whitespace).filter { it.isNotEmpty() } + + /** The tokens a member is found by: each word of [displayName], and [username] without its `@`. */ + fun tokens(displayName: String?, username: String?): Set = buildSet { + displayName?.let { addAll(words(it)) } + username?.removePrefix("@")?.let { addAll(words(it)) } + } + + /** [query]'s words, each a prefix to match. A leading `@` on a word is the mention trigger, not part of it. */ + fun queryWords(query: String): List = + words(query).map { it.removePrefix("@") }.filter { it.isNotEmpty() } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ServerRosterSearchSource.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ServerRosterSearchSource.kt new file mode 100644 index 0000000000..f7fbfd7c36 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/ServerRosterSearchSource.kt @@ -0,0 +1,59 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MentionSuggestion +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.MemberMatch +import com.flipcash.shared.chat.RosterSearchSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Inject + +/** + * Searches the pool `Chat.GetMentionSuggestions` returns for a group, held by [pool]. + * + * The pool is filtered locally with [MentionText]'s word-prefix rule and keeps the server's + * order: a bare `@` shows its head. When the fetch fails (DENIED, NOT_FOUND, or the call never + * reached the server) the result is empty, with no fallback; the user can still type a handle by hand. + */ +internal class ServerRosterSearchSource( + private val pool: MentionSuggestionPool, + private val userManager: UserManager, + private val scope: CoroutineScope, +) : RosterSearchSource { + + @Inject constructor( + pool: MentionSuggestionPool, + userManager: UserManager, + ) : this(pool, userManager, CoroutineScope(SupervisorJob() + Dispatchers.IO)) + + override suspend fun search(chatId: ChatId, query: String, limit: Int): List { + val held = pool.pool(scope, chatId).getOrElse { return emptyList() } + val selfId = userManager.accountId + val words = MentionText.queryWords(query) + return held.asSequence() + .filter { it.userProfile.userId != null && it.userProfile.userId != selfId } + .filter { words.isEmpty() || matchesEveryWord(it, words) } + .take(limit) + .map { it.toMatch() } + .toList() + } + + // A new composing session: fetch the pool again. + override suspend fun refresh(chatId: ChatId) { + pool.startSession(scope, chatId) + } + + private fun matchesEveryWord(suggestion: MentionSuggestion, words: List): Boolean { + val tokens = MentionText.tokens(suggestion.userProfile.displayName, suggestion.userProfile.username) + return words.all { word -> tokens.any { it.startsWith(word) } } + } + + private fun MentionSuggestion.toMatch() = MemberMatch( + userId = userProfile.userId!!, + displayName = userProfile.displayName, + username = userProfile.username, + profilePicture = userProfile.profilePicture, + ) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 281b19903c..53c746ae83 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -28,6 +28,7 @@ import com.flipcash.shared.chat.EventStreamOperations import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.MessageLinkPrefetch +import com.flipcash.shared.chat.internal.MentionPoolUpdates import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.core.ID import com.getcode.utils.TraceType @@ -92,6 +93,7 @@ class EventStreamDelegate @Inject constructor( private val analytics: FlipcashAnalytics, private val exchange: Exchange, private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, + private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None, ) : EventStreamOperations { companion object { @@ -363,6 +365,7 @@ class EventStreamDelegate @Inject constructor( linkPrefetch.prefetch(resolvedMessages, MessageLinkPrefetch.LIVE_WAIT) messageDataSource.upsert(chatId, resolvedMessages) typingTracker.messageArrived(chatId, resolvedMessages.mapNotNull { it.senderId }) + mentionPool.onMessages(chatId, resolvedMessages.mapNotNull { m -> m.senderId?.let { it to m.timestamp } }) resolvedMessages.maxByOrNull { it.messageId } } else null diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MentionTextTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MentionTextTest.kt new file mode 100644 index 0000000000..9815b694e9 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MentionTextTest.kt @@ -0,0 +1,25 @@ +package com.flipcash.shared.chat + +import com.flipcash.shared.chat.internal.MentionText +import org.junit.Test +import kotlin.test.assertEquals + +class MentionTextTest { + + @Test + fun `folds case and diacritics`() { + assertEquals("erica", MentionText.normalize("Érica")) + assertEquals("istanbul", MentionText.normalize("İstanbul")) + } + + @Test + fun `tokens are the display name's words and the handle`() { + assertEquals(setOf("eli", "zane"), MentionText.tokens("Eli Zane", "@eli")) + } + + @Test + fun `the leading at is the trigger, not part of the query word`() { + assertEquals(listOf("er", "zan"), MentionText.queryWords("@er zan")) + assertEquals(emptyList(), MentionText.queryWords("@")) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt new file mode 100644 index 0000000000..1e0f1a203f --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt @@ -0,0 +1,155 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.UserProfileDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.GetMentionSuggestionsError +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MentionSuggestion +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.MentionSuggestionPool +import com.flipcash.shared.chat.internal.ServerRosterSearchSource +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class ServerRosterSearchSourceTest { + + private val chatId = ChatId("c0ffee") + private val self = listOf(1) + + private val chatController = mockk() + private val profiles = mockk(relaxed = true) + private val userManager = mockk { every { accountId } returns self } + private val pool = MentionSuggestionPool(chatController, profiles) + + private fun TestScope.subject() = ServerRosterSearchSource(pool, userManager, backgroundScope) + + private fun suggestion(id: Int, name: String, username: String, sentAt: Long? = null) = MentionSuggestion( + userProfile = UserProfile.Empty.copy(userId = listOf(id.toByte()), displayName = name, username = username), + lastSentAt = sentAt?.let { Instant.fromEpochMilliseconds(it) }, + ) + + private val erin = suggestion(2, "Erin", "erin", sentAt = 300) + private val bob = suggestion(3, "Bob", "bob", sentAt = 200) + private val eli = suggestion(4, "Eli Zane", "eli", sentAt = 100) + + private fun serverReturns(vararg pool: MentionSuggestion) { + coEvery { chatController.getMentionSuggestions(chatId) } returns Result.success(pool.toList()) + } + + private suspend fun RosterSearchSource.names(query: String, limit: Int = 20) = + search(chatId, query, limit).map { it.displayName } + + @Test + fun `filtering keeps the server's order`() = runTest { + serverReturns(erin, bob, eli) + val subject = subject() + + assertEquals(listOf("Erin", "Eli Zane"), subject.names("@e")) + // Any word of the display name, and the handle. + assertEquals(listOf("Eli Zane"), subject.names("@zan")) + assertEquals(listOf("Bob"), subject.names("@bo")) + } + + @Test + fun `a bare at shows the head of the pool`() = runTest { + serverReturns(erin, bob, eli) + + assertEquals(listOf("Erin", "Bob"), subject().names("@", limit = 2)) + } + + @Test + fun `the pool is fetched once per composing session`() = runTest { + serverReturns(erin, bob, eli) + val subject = subject() + + subject.refresh(chatId) + subject.names("@") + subject.names("@e") + subject.names("@er") + coVerify(exactly = 1) { chatController.getMentionSuggestions(chatId) } + + subject.refresh(chatId) + subject.names("@") + coVerify(exactly = 2) { chatController.getMentionSuggestions(chatId) } + } + + @Test + fun `the pool's profiles are stored`() = runTest { + serverReturns(erin, bob) + + subject().names("@") + + coVerify { profiles.store(erin.userProfile.userId!!, erin.userProfile) } + coVerify { profiles.store(bob.userProfile.userId!!, bob.userProfile) } + } + + @Test + fun `a new message moves a held sender to the front`() = runTest { + serverReturns(erin, bob, eli) + val subject = subject() + subject.names("@") + + pool.onMessages(chatId, listOf(eli.userProfile.userId!! to Instant.fromEpochMilliseconds(400))) + + assertEquals(listOf("Eli Zane", "Erin", "Bob"), subject.names("@")) + } + + @Test + fun `a message older than the sender's last one, or from someone not in the pool, changes nothing`() = runTest { + serverReturns(erin, bob, eli) + val subject = subject() + subject.names("@") + + pool.onMessages( + chatId, + listOf( + bob.userProfile.userId!! to Instant.fromEpochMilliseconds(150), + listOf(9) to Instant.fromEpochMilliseconds(500), + ), + ) + + assertEquals(listOf("Erin", "Bob", "Eli Zane"), subject.names("@")) + } + + @Test + fun `the caller is never suggested`() = runTest { + serverReturns(erin, suggestion(1, "Me", "me")) + + assertEquals(listOf("Erin"), subject().names("@")) + } + + @Test + fun `DENIED returns nothing`() = runTest { + coEvery { chatController.getMentionSuggestions(chatId) } returns + Result.failure(GetMentionSuggestionsError.Denied()) + val subject = subject() + + assertEquals(emptyList(), subject.names("@e")) + subject.refresh(chatId) + assertEquals(emptyList(), subject.names("@")) + } + + @Test + fun `NOT_FOUND returns nothing`() = runTest { + coEvery { chatController.getMentionSuggestions(chatId) } returns + Result.failure(GetMentionSuggestionsError.NotFound()) + + assertEquals(emptyList(), subject().names("@")) + } + + @Test + fun `a transport error returns nothing`() = runTest { + coEvery { chatController.getMentionSuggestions(chatId) } returns + Result.failure(GetMentionSuggestionsError.Other(java.io.IOException("UNAVAILABLE"))) + + assertEquals(emptyList(), subject().names("@")) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt index 2deadf2a22..ff5d74dbb2 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewerState @@ -84,6 +85,14 @@ class ChatController @Inject constructor( return repository.getRoster(owner, chatId, queryOptions) } + /** The pool [chatId] offers for `@` mentions. See [ChatRepository.getMentionSuggestions]. */ + suspend fun getMentionSuggestions(chatId: ChatId): Result> { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getMentionSuggestions(owner, chatId) + } + /** Edits [chatId] per [parameters]. See [ChatRepository.editChat] for no-op semantics. */ suspend fun editChat( chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt index 1f81737653..69c4014989 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt @@ -161,7 +161,8 @@ internal object FlipcashModule { internal fun providesChatRepository( service: ChatService, mapper: ChatMetadataMapper, - ): ChatRepository = InternalChatRepository(service, mapper) + userProfileMapper: UserProfileMapper, + ): ChatRepository = InternalChatRepository(service, mapper, userProfileMapper) @Provides internal fun providesEventStreamingRepository( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index d6ac5b89e1..77a26baeec 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -148,6 +148,22 @@ internal class ChatApi @Inject constructor( } } + suspend fun getMentionSuggestions( + owner: KeyPair, + chatId: ChatId, + ): RpcChatService.GetMentionSuggestionsResponse { + val request = RpcChatService.GetMentionSuggestionsRequest.newBuilder() + .setChatId(chatId.asChatId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getMentionSuggestions(request) + } + } + suspend fun editChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index 18dd75ecb8..ac19966081 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.EditChatError import com.flipcash.services.models.GetChatError import com.flipcash.services.models.GetDmChatFeedError import com.flipcash.services.models.GetGroupChatFeedError +import com.flipcash.services.models.GetMentionSuggestionsError import com.flipcash.services.models.GetRosterError import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.LeaveChatError @@ -121,6 +122,28 @@ internal class ChatService @Inject constructor( ) } + suspend fun getMentionSuggestions( + owner: KeyPair, + chatId: ChatId, + ): Result { + return runCatching { + api.getMentionSuggestions(owner, chatId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetMentionSuggestionsResponse.Result.OK -> Result.success(response) + RpcChatService.GetMentionSuggestionsResponse.Result.DENIED -> Result.failure(GetMentionSuggestionsError.Denied()) + RpcChatService.GetMentionSuggestionsResponse.Result.NOT_FOUND -> Result.failure(GetMentionSuggestionsError.NotFound()) + RpcChatService.GetMentionSuggestionsResponse.Result.UNRECOGNIZED -> Result.failure(GetMentionSuggestionsError.Unrecognized()) + else -> Result.failure(GetMentionSuggestionsError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetMentionSuggestionsError.Other(cause = it) }) + } + ) + } + suspend fun joinChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index 556bb55b04..b91923d7a6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -1,6 +1,7 @@ package com.flipcash.services.internal.repositories import com.flipcash.services.internal.domain.ChatMetadataMapper +import com.flipcash.services.internal.domain.UserProfileMapper import com.flipcash.services.internal.network.extensions.toChatMember import com.flipcash.services.internal.network.extensions.toPagingToken import com.flipcash.services.internal.network.extensions.toRosterSummary @@ -15,6 +16,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewerState @@ -22,10 +24,12 @@ import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.repository.ChatRepository import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.utils.ErrorUtils +import kotlin.time.Instant internal class InternalChatRepository( private val service: ChatService, private val mapper: ChatMetadataMapper, + private val userProfileMapper: UserProfileMapper, ) : ChatRepository { override suspend fun getChat( owner: KeyPair, @@ -85,6 +89,22 @@ internal class InternalChatRepository( ) } + override suspend fun getMentionSuggestions( + owner: KeyPair, + chatId: ChatId, + ): Result> = service.getMentionSuggestions(owner, chatId) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + response.suggestionsList.map { suggestion -> + MentionSuggestion( + userProfile = userProfileMapper.map(suggestion.userProfile), + lastSentAt = if (suggestion.hasLastSentAt()) { + Instant.fromEpochSeconds(suggestion.lastSentAt.seconds, suggestion.lastSentAt.nanos) + } else null, + ) + } + } + override suspend fun editChat( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index b03cf69ca6..0fb89be241 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -385,6 +385,16 @@ sealed class GetRosterError( data class Other(override val cause: Throwable? = null) : GetRosterError(message = cause?.message, cause = cause), NotifiableError } +sealed class GetMentionSuggestionsError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetMentionSuggestionsError("Denied") + class NotFound : GetMentionSuggestionsError("Not found") + class Unrecognized : GetMentionSuggestionsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetMentionSuggestionsError(message = cause?.message, cause = cause), NotifiableError +} + sealed class JoinChatError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MentionSuggestion.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MentionSuggestion.kt new file mode 100644 index 0000000000..dca0df6d8d --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MentionSuggestion.kt @@ -0,0 +1,17 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.UserProfile +import kotlin.time.Instant + +/** + * One member of the pool a group offers for `@` mentions, from `Chat.GetMentionSuggestions`. + * + * The pool is ranked by the server, most relevant first, and is neither paged nor complete: filter + * it locally as the user types and keep its order. [userProfile] always carries a user id and a + * username. + */ +data class MentionSuggestion( + val userProfile: UserProfile, + // When this user last sent a message in the chat; null when suggested for another reason. + val lastSentAt: Instant?, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt index d7cbc9931f..b4d3f34ead 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewerState @@ -60,6 +61,17 @@ interface ChatRepository { queryOptions: QueryOptions = QueryOptions(), ): Result + /** + * The pool of members [chatId], a group, offers for `@` mentions: ranked by the server, most + * relevant first, and neither paged nor complete. Never the caller, users the caller blocked, + * or users without a username. Fails with [com.flipcash.services.models.GetMentionSuggestionsError.Denied] + * for a DM or when the caller may not speak. + */ + suspend fun getMentionSuggestions( + owner: KeyPair, + chatId: ChatId, + ): Result> + /** * Edits [chatId], a group chat, applying only the fields set in [parameters]. Unset fields * are left unchanged; an all-null [parameters] is a valid no-op. Requires diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt index 15ed325610..e27ff54ec4 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -15,6 +15,7 @@ import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ViewMode import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.repository.ChatRepository import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 @@ -502,6 +503,11 @@ private class FakeChatRepository : ChatRepository { return startChatResult } + override suspend fun getMentionSuggestions( + owner: Ed25519.KeyPair, + chatId: ChatId, + ): Result> = Result.success(emptyList()) + override suspend fun getRoster( owner: Ed25519.KeyPair, chatId: ChatId, From 6ba5d2055899cc0442e8e9f8eaeefca1a5e428c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 13:37:09 -0400 Subject: [PATCH 7/9] chore(deps): bump flipcash2-client-protocol to 0.15.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8341e9c707..a58ff1135c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.3" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.6.0" -flipcash2-client-protocol = "0.14.1" +flipcash2-client-protocol = "0.15.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, From 64d55eecef2a109ed6afdd08c924d33ea5552679 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 09:15:19 -0400 Subject: [PATCH 8/9] fix(messenger): move the composer's cards and the transcript together Opening the mention list or the reply strip moved the transcript to its final inset on the first frame, ahead of the card. AnimatedVisibility's expandVertically reports the finished size to the lookahead pass, and the scaffold insets the transcript from that pass. ComposerAccessorySlot sizes the card from the reply spring in every pass instead, so the card, the input row and the transcript share one height each frame. The bar's fade was sized from the bar's height a frame late, which held the composer one frame high whenever a card above it closed. It now matches the bar's size. The mention list's height follows the rows on show, on the same spring: a narrower query shrinks the card instead of cutting rows, and opening the reply strip takes a row (4 to 3) without a jump. Dropped rows fade and the rest slide up into the gap. When the matches change, the list goes back to the top; keeping the old row in view scrolled the list while the card grew from 1 row to 4. --- .../internal/mention/MentionSuggestionList.kt | 61 +++++++++++++---- .../screens/components/ChatBottomBar.kt | 9 ++- .../screens/components/ComposerAccessories.kt | 65 +++++++++++++++---- .../flipcash/shared/chat/ui/ChatAnimations.kt | 24 ++++--- 4 files changed, 123 insertions(+), 36 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt index e5e7211d7f..6a959c350e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt @@ -1,21 +1,26 @@ package com.flipcash.app.messenger.internal.mention +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,6 +36,7 @@ import com.getcode.ui.utils.generateComplementaryColorPalette import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.shared.chat.MemberMatch import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.chat.ui.ChatAnimations import com.getcode.theme.CodeTheme import com.getcode.ui.components.chat.ChatInputDefaults import dev.chrisbanes.haze.HazeInput @@ -56,8 +62,29 @@ internal fun MentionSuggestionList( val shape = CodeTheme.shapes.medium val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) val border = CodeTheme.dimens.border + // Sized to the rows on show, up to [maxRows], and moved there on the reply spring. The count + // changes as typing narrows the matches, and the cap changes when the reply strip opens or closes + // under the list (4 rows to 3 and back); snapping either cuts or adds a row in one frame, and the + // transcript, inset by this bar, would jump with it. + val height by animateDpAsState( + mentionListHeight(minOf(matches.size, maxRows), border), + ChatAnimations.replySurfaceDp, + ) + + // A LazyColumn keeps the item it was showing in view across a data change, by key. When typing + // widens the matches, the one row that was up lands lower in the new order, and the list + // scrolled down to follow it while the card grew. The order is a ranking, so the top is what + // belongs in view: pin it before the new matches are measured. + val listState = rememberLazyListState() + val keys = matches.map { it.userId } + var lastKeys by remember { mutableStateOf(keys) } + if (keys != lastKeys) { + lastKeys = keys + listState.requestScrollToItem(0) + } LazyColumn( + state = listState, modifier = modifier .fillMaxWidth() .clip(shape) @@ -65,20 +92,30 @@ internal fun MentionSuggestionList( // The input field's own fill over the same glass, so card and field read as one surface. .background(ChatInputDefaults.ContainerColor) .border(border, CodeTheme.colors.divider, shape) - .heightIn(max = mentionListHeight(maxRows, border)), + .height(height), ) { itemsIndexed(matches, key = { _, match -> match.userId.toString() }) { index, match -> - if (index > 0) { - // Starts under the name, clear of the avatar column; none after the last row. - Box( - Modifier - .padding(start = MentionSuggestionListDefaults.dividerInset) - .fillMaxWidth() - .height(border) - .background(CodeTheme.colors.divider), - ) + // Rows a narrower query drops fade out, and the survivors slide up into the gap, on the + // same spring as the card closing around them. + Column( + Modifier.animateItem( + fadeInSpec = ChatAnimations.replySurface, + placementSpec = ChatAnimations.replySurfaceIntOffset, + fadeOutSpec = ChatAnimations.replySurface, + ), + ) { + if (index > 0) { + // Starts under the name, clear of the avatar column; none after the last row. + Box( + Modifier + .padding(start = MentionSuggestionListDefaults.dividerInset) + .fillMaxWidth() + .height(border) + .background(CodeTheme.colors.divider), + ) + } + MentionRow(match = match, onClick = { onSelect(match) }) } - MentionRow(match = match, onClick = { onSelect(match) }) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 6b338f802a..9f5eab1c58 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -182,12 +182,15 @@ internal fun UserControlBottomBar( // The transcript runs under the bar and dissolves into the background here, from the // bar's top edge to the bottom of the screen (or the keyboard's top edge). The bar has no // surface of its own and floats over it. + // + // Sized to the bar rather than to its last measured height: the Box takes the larger of + // its children, so a height read back from the previous frame held the bar one frame + // taller whenever it shrank. The scaffold bottom-aligns the bar from that height, so the + // composer stepped off its rest position and back as a card above it left. val fadeColor = CodeTheme.colors.background Box( modifier = Modifier - .fillMaxWidth() - .height(buttonHeight) - .align(Alignment.BottomCenter) + .matchParentSize() .drawWithGradient( brush = { startY, endY -> composerFade(fadeColor, startY, endY) }, startY = { 0f }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt index 0c888eb3f4..2be2367917 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt @@ -1,14 +1,18 @@ package com.flipcash.app.messenger.internal.screens.components -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import com.flipcash.app.messenger.internal.ChatViewModel @@ -19,6 +23,7 @@ import com.flipcash.shared.chat.ui.ChatAnimations import com.flipcash.shared.chat.ui.ComposerReplyStrip import com.getcode.theme.CodeTheme import dev.chrisbanes.haze.HazeState +import kotlin.math.roundToInt /** * A card stacked above the composer's input row. @@ -70,11 +75,7 @@ internal fun ComposerAccessoryStack( Column { var lastMatches by remember { mutableStateOf(mentions?.matches.orEmpty()) } mentions?.let { lastMatches = it.matches } - AnimatedVisibility( - visible = mentions != null, - enter = ChatAnimations.replySurfaceEnter, - exit = ChatAnimations.replySurfaceExit, - ) { + ComposerAccessorySlot(visible = mentions != null) { MentionSuggestionList( matches = lastMatches, maxRows = mentionRows, @@ -89,11 +90,7 @@ internal fun ComposerAccessoryStack( var lastQuote by remember { mutableStateOf(reply?.quote) } reply?.let { lastQuote = it.quote } - AnimatedVisibility( - visible = reply != null, - enter = ChatAnimations.replySurfaceEnter, - exit = ChatAnimations.replySurfaceExit, - ) { + ComposerAccessorySlot(visible = reply != null) { lastQuote?.let { quote -> ComposerReplyStrip( quote = quote, @@ -110,3 +107,49 @@ internal fun ComposerAccessoryStack( } } } + +/** + * Reveals [content] by growing its slot from nothing to its natural height, and takes it away by + * the reverse: [ChatAnimations.replySurface] running 0 to 1 and back, content anchored to the top + * and clipped by the moving edge, fading on the way out only. + * + * The slot's height is the content's natural height times the animated fraction, read in layout in + * every pass. That is the point of doing this by hand: `AnimatedVisibility`'s expand reports the + * finished height to a lookahead pass, and the scaffold insets the transcript from that pass, so + * the transcript jumped up the instant a card was added, ahead of the card, while the exit (whose + * lookahead size is the current one) followed it. Here both directions give the same height in + * both passes, so the transcript, the card and the input row move together. + * + * [content] stays composed until the slot has closed, and is not composed at all when it starts + * closed. + */ +@Composable +internal fun ComposerAccessorySlot( + visible: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + // Starts at its resting value, so a card that is already up when the screen opens is simply there. + val reveal = remember { Animatable(if (visible) 1f else 0f) } + var composed by remember { mutableStateOf(visible) } + if (visible) composed = true + + LaunchedEffect(visible) { + reveal.animateTo(if (visible) 1f else 0f, ChatAnimations.replySurface) + if (!visible) composed = false + } + + if (!composed) return + Layout( + content = content, + modifier = modifier + .graphicsLayer { alpha = if (visible) 1f else reveal.value } + .clipToBounds(), + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) } + val width = placeables.maxOfOrNull { it.width } ?: 0 + val natural = placeables.maxOfOrNull { it.height } ?: 0 + val height = (natural * reveal.value).roundToInt() + layout(width, height) { placeables.forEach { it.place(0, 0) } } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index daa7ecedb7..fc0a3b44a1 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -12,7 +12,6 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.ui.Alignment import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Dp @@ -81,17 +80,22 @@ object ChatAnimations { // frame, so an overshoot here drags every message past where it settles and back. val replySurface: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 816f) - private val replySurfaceIntSize: SpringSpec = + // The same spring for a card that resizes while another one opens or closes beside it: the + // mention list giving up a row to the reply strip moves with the strip, not ahead of it. + val replySurfaceDp: SpringSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 816f) + val replySurfaceIntOffset: SpringSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 816f) - // Asymmetric, as on iOS: nothing fades in, because the clip edge uncovering the quote is the - // whole effect, and a fade on top of it reads as a second animation. Going away it does fade, - // so the quote dissolves rather than being sliced off by an edge moving over text that is still - // fully opaque. - val replySurfaceEnter: EnterTransition = - expandVertically(replySurfaceIntSize, expandFrom = Alignment.Top) - val replySurfaceExit: ExitTransition = - shrinkVertically(replySurfaceIntSize, shrinkTowards = Alignment.Top) + fadeOut(replySurface) + // Reply mode and mention list, enter and exit: the card is revealed by this spring running 0 to 1 + // and back, with the content clipped by the moving edge (see ComposerAccessorySlot). Nothing + // fades in, because the clip edge uncovering the card is the whole effect and a fade on top of + // it reads as a second animation; going away it does fade, so the card dissolves rather than + // being sliced off by an edge moving over content that is still fully opaque. + // + // Not expandVertically/shrinkVertically: those report their target size to a lookahead pass, + // and the scaffold reads the bar's height from that pass to inset the transcript, so the + // transcript would jump to the finished height on the first frame of an enter. // The flash a jump leaves on the message it landed on: white at full, held long enough to be // caught by an eye still following the scroll, then faded off. From d4c09f3174ac7f3430999bdf12c6aba07bb7faa7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 09:19:00 -0400 Subject: [PATCH 9/9] test(chat): suggest someone who has left the group The server's pool is shown without a membership check, so a person it offers after they left the group still appears. --- .../shared/chat/ServerRosterSearchSourceTest.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt index 1e0f1a203f..f19c3b021a 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt @@ -119,6 +119,18 @@ class ServerRosterSearchSourceTest { assertEquals(listOf("Erin", "Bob", "Eli Zane"), subject.names("@")) } + @Test + fun `someone who has left the group is still suggested`() = runTest { + // Spoke in the group, then left: no member row on this device. The server's pool is shown as + // it comes, with no membership check, so who is offered is the server's call. + val former = suggestion(5, "Fern", "fern", sentAt = 50) + serverReturns(erin, former) + val subject = subject() + + assertEquals(listOf("Fern"), subject.names("@fe")) + assertEquals(listOf("Erin", "Fern"), subject.names("@")) + } + @Test fun `the caller is never suggested`() = runTest { serverReturns(erin, suggestion(1, "Me", "me"))