Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ChatViewModel.State, ChatViewModel.Event>(
initialState = State(),
Expand Down Expand Up @@ -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<MemberMatch> = emptyList(),
/**
* A message the transcript has been asked to scroll to, held until the list consumes it.
*
Expand Down Expand Up @@ -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<MemberMatch>) : 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
Expand Down Expand Up @@ -1272,6 +1295,7 @@ internal class ChatViewModel @Inject constructor(
initTokenAndExchangeObservers()
initTypingHandlers()
initSendHandlers()
initMentionPicker()
initMessageActionHandlers()
}
}
Expand Down Expand Up @@ -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<Event.PickMention>()
.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<Event.SendMessage>()
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Int> {
// 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<MemberMatch>.mentionable(): List<MemberMatch> = 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
}
Loading
Loading