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..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 @@ -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,12 +172,14 @@ 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 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 @@ -213,6 +221,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 +321,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 +709,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 +1295,7 @@ internal class ChatViewModel @Inject constructor( initTokenAndExchangeObservers() initTypingHandlers() initSendHandlers() + initMentionPicker() initMessageActionHandlers() } } @@ -2254,6 +2278,63 @@ 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: 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. + val refreshes = MutableStateFlow(0) + 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, refreshes) { canMention, query, refresh -> + query.takeIf { canMention } to refresh + } + .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) } + .onSuccess { refreshes.update { it + 1 } } + } + } + 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 +3005,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..fb6da91820 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionQuery.kt @@ -0,0 +1,75 @@ +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. + * 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 { + // 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 + if (followedBySpace) 1 else 0 +} + +/** 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..6a959c350e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/mention/MentionSuggestionList.kt @@ -0,0 +1,184 @@ +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.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 +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 +import com.flipcash.shared.chat.ui.ChatAnimations +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 +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, 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. + * + * Metrics follow the design (mention row set, node 10633:68; spec, node 10662:64911). + */ +@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 + // 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) + .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) + .height(height), + ) { + itemsIndexed(matches, key = { _, match -> match.userId.toString() }) { index, match -> + // 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) }) + } + } + } +} + +@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, 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.copy(fontWeight = FontWeight.Medium), + color = MentionSuggestionListDefaults.usernameColor, + 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 = 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 } 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..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 @@ -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,206 @@ 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. + // + // 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 + .matchParentSize() + .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..2be2367917 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerAccessories.kt @@ -0,0 +1,155 @@ +package com.flipcash.app.messenger.internal.screens.components + +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 +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 +import kotlin.math.roundToInt + +/** + * 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 } + ComposerAccessorySlot(visible = mentions != null) { + 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 } + ComposerAccessorySlot(visible = reply != null) { + 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"), + ) + } + } + } +} + +/** + * 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/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..d1a5f62372 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -0,0 +1,277 @@ +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.CompletableDeferred +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) } + } + + @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, + 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..88643f9aed --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/mention/MentionQueryTest.kt @@ -0,0 +1,151 @@ +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 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" + 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-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. 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)) } 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?, +) 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..f19c3b021a --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ServerRosterSearchSourceTest.kt @@ -0,0 +1,167 @@ +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 `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")) + + 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/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, 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,