Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions feature/messaging/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ kotlin {

implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)
implementation(libs.kotlinx.collections.immutable)

// JetBrains Material 3 Adaptive (multiplatform ListDetailPaneScaffold)
implementation(libs.jetbrains.compose.material3.adaptive)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
Expand All @@ -99,7 +102,6 @@ import org.meshtastic.core.resources.type_a_message
import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.InlineStyle
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.inlineMarkdownStyleRanges
import org.meshtastic.core.ui.component.smartScrollToIndex
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Send
Expand Down Expand Up @@ -150,6 +152,13 @@ fun MessageScreen(
val focusManager = LocalFocusManager.current

val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val mentionCandidates by remember {
derivedStateOf {
nodes
.associate { it.user.id to MentionCandidate(it.user.id, it.user.long_name, it.user.short_name) }
.toPersistentMap()
}
}
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -435,7 +444,7 @@ fun MessageScreen(
isEnabled = connectionState is ConnectionState.Connected,
isHomoglyphEncodingEnabled = homoglyphEncodingEnabled,
textFieldState = messageInputState,
nodes = nodes,
mentionCandidates = mentionCandidates,
onSendMessage = {
val messageText = messageInputState.text.toString().trim { it.isWhitespace() }
if (messageText.isNotEmpty()) {
Expand Down Expand Up @@ -531,12 +540,132 @@ internal fun currentMentionQuery(text: String, selection: TextRange): MentionQue
return MentionQuery(at, cursor, query)
}

private fun Node.matchesMention(query: String): Boolean {
if (query.isEmpty()) return true
val q = query.lowercase()
return user.long_name.lowercase().contains(q) ||
user.short_name.lowercase().contains(q) ||
user.id.lowercase().contains(q)
/**
* Lightweight mention target — contains only fields that affect composer presentation. A telemetry-only [Node] change
* leaves a [MentionCandidate] unchanged so the composable's recomposition scope stays narrow. Normalized fields are
* cached once so matching does not allocate three lowercase strings per candidate on every keystroke.
*/
internal data class MentionCandidate(val id: String, val longName: String, val shortName: String) {
private val normalizedId = id.lowercase()
private val normalizedLongName = longName.lowercase()
private val normalizedShortName = shortName.lowercase()

fun matchesNormalizedQuery(query: String): Boolean =
normalizedLongName.contains(query) || normalizedShortName.contains(query) || normalizedId.contains(query)
}

internal fun matchingMentionCandidates(
candidates: Collection<MentionCandidate>,
query: String,
limit: Int = MENTION_SUGGESTION_LIMIT,
): List<MentionCandidate> = when {
limit <= 0 -> emptyList()

query.isEmpty() -> candidates.take(limit)

else -> {
val normalizedQuery = query.lowercase()
candidates.asSequence().filter { it.matchesNormalizedQuery(normalizedQuery) }.take(limit).toList()
}
}

/** Regex-backed inline-markdown style ranges for the live TextField path — avoids the IntelliJ GFM parser. */
private val LIVE_BOLD = Regex("\\*\\*([^*]+)\\*\\*")
private val LIVE_ITALIC = Regex("(?<!\\*)\\*([^*]+)\\*(?!\\*)") // single *, not part of **
private val LIVE_STRIKE = Regex("~~([^~]+)~~")
private val LIVE_CODE = Regex("`([^`]+)`")

internal data class LiveStyleSpan(val range: IntRange, val style: InlineStyle)

/**
* Detects live-markdown spans in [source] using simple regex patterns instead of a full GFM AST parser.
*
* The live composer intentionally previews only asterisk emphasis, strikethrough, and inline code. Underscore emphasis
* remains raw while editing, but the sent message still uses the full Markdown renderer.
*/
internal fun liveInlineMarkdownStyleRanges(source: String): List<LiveStyleSpan> {
if (!source.any { it in LIVE_STYLE_DELIMITER_SET }) return emptyList()
val codeMatches = LIVE_CODE.findAll(source).toList()
// Returns true when either delimiter boundary of this match falls inside a code span. Markdown entirely inside
// code, or malformed markdown crossing a code boundary, must not add styling. A code span nested inside outer
// emphasis is still allowed, so outer bold text can retain both its bold span and an inner code span.
val isBlockedByCodeSpan: (MatchResult) -> Boolean = { match ->
codeMatches.any { codeMatch -> match.range.first in codeMatch.range || match.range.last in codeMatch.range }
}
return buildList {
LIVE_BOLD.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Bold))
}
LIVE_ITALIC.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Italic))
}
LIVE_STRIKE.findAll(source).filterNot(isBlockedByCodeSpan).forEach {
add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Strikethrough))
}
codeMatches.forEach { add(LiveStyleSpan(it.groups[1]!!.range, InlineStyle.Code)) }
}
.sortedWith(compareBy<LiveStyleSpan>({ it.range.first }, { it.range.last }, { it.style.ordinal }))
}

private val LIVE_STYLE_DELIMITER_SET = setOf('*', '~', '`')

internal data class MentionReplacement(val range: IntRange, val text: String)

internal data class MentionOutputPlan(val replacements: List<MentionReplacement>, val styleSpans: List<LiveStyleSpan>)

/**
* Builds presentation edits from the immutable source buffer. Markdown is parsed before friendly-name replacement so
* display names containing `*`, `~`, or backticks cannot introduce formatting that was not present in the stored text.
*/
internal fun mentionOutputPlan(source: String, candidatesById: Map<String, MentionCandidate>): MentionOutputPlan {
val replacements =
MENTION_TOKEN_REGEX.findAll(source)
.mapNotNull { match ->
val candidate = candidatesById[match.groupValues[1]] ?: return@mapNotNull null
MentionReplacement(match.range, "@" + candidate.longName.ifEmpty { candidate.shortName })
}
.toList()
val styleSpans = remapStyleSpans(liveInlineMarkdownStyleRanges(source), replacements)
return MentionOutputPlan(replacements, styleSpans)
}

/** Remaps source-buffer style ranges through non-overlapping mention replacements. */
internal fun remapStyleSpans(spans: List<LiveStyleSpan>, replacements: List<MentionReplacement>): List<LiveStyleSpan> {
if (spans.isEmpty() || replacements.isEmpty()) return spans
val sortedReplacements = replacements.sortedBy { it.range.first }
return spans.mapNotNull { span ->
val start = remapBoundary(span.range.first, sortedReplacements, preferReplacementEnd = false)
val endExclusive = remapBoundary(span.range.last + 1, sortedReplacements, preferReplacementEnd = true)
if (start >= endExclusive) null else span.copy(range = start until endExclusive)
}
}

private fun remapBoundary(
sourceOffset: Int,
replacements: List<MentionReplacement>,
preferReplacementEnd: Boolean,
): Int {
var delta = 0
var mappedOffset: Int? = null
val iterator = replacements.iterator()
while (iterator.hasNext() && mappedOffset == null) {
val replacement = iterator.next()
val sourceStart = replacement.range.first
val sourceEndExclusive = replacement.range.last + 1
mappedOffset =
when {
sourceOffset <= sourceStart -> sourceOffset + delta

sourceOffset < sourceEndExclusive ->
sourceStart + delta + if (preferReplacementEnd) replacement.text.length else 0

else -> {
delta += replacement.text.length - (sourceEndExclusive - sourceStart)
null
}
}
}
return mappedOffset ?: sourceOffset + delta
}

/**
Expand All @@ -545,13 +674,18 @@ private fun Node.matchesMention(query: String): Boolean {
* and the raw markdown delimiters, so the bytes sent are unchanged.
*/
@OptIn(ExperimentalFoundationApi::class)
private fun mentionOutputTransformation(nodesById: Map<String, Node>) = OutputTransformation {
for (match in MENTION_TOKEN_REGEX.findAll(toString()).toList().asReversed()) {
val node = nodesById[match.groupValues[1]] ?: continue
replace(match.range.first, match.range.last + 1, "@" + node.user.long_name.ifEmpty { node.user.short_name })
private fun mentionOutputTransformation(candidatesById: Map<String, MentionCandidate>) = OutputTransformation {
val source = toString()
// Fast path: plain text with no mention tokens or markdown delimiters — skip all scanning.
val hasMention = source.indexOf('@') >= 0
val needsStyle = hasMention || source.any { it in LIVE_STYLE_DELIMITER_SET }
if (!needsStyle) return@OutputTransformation

val plan = mentionOutputPlan(source, candidatesById)
for (replacement in plan.replacements.asReversed()) {
replace(replacement.range.first, replacement.range.last + 1, replacement.text)
}
// Live markdown styling over the (mention-substituted) buffer. Delimiters stay visible; only spans are added.
for (span in inlineMarkdownStyleRanges(toString())) {
for (span in plan.styleSpans) {
val spanStyle =
when (span.style) {
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
Expand All @@ -569,7 +703,8 @@ private fun mentionOutputTransformation(nodesById: Map<String, Node>) = OutputTr
*
* @param isEnabled Whether the input field should be enabled.
* @param textFieldState The [TextFieldState] managing the input's text.
* @param nodes Known nodes, used for the `@`-mention autocomplete and friendly-name display.
* @param mentionCandidates Identity-stable node-id → mention entry. Only changes when a node's id or display name
* changes — telemetry-only updates leave this map structurally identical, preventing recomposition churn.
* @param modifier The modifier for this composable.
* @param maxByteSize The maximum allowed size of the message in bytes.
* @param onSendMessage Callback invoked when the send button is pressed or send IME action is triggered.
Expand All @@ -580,7 +715,7 @@ private fun MessageInput(
isEnabled: Boolean,
isHomoglyphEncodingEnabled: Boolean,
textFieldState: TextFieldState,
nodes: List<Node>,
mentionCandidates: ImmutableMap<String, MentionCandidate>,
modifier: Modifier = Modifier,
maxByteSize: Int = MESSAGE_CHARACTER_LIMIT_BYTES,
onSendMessage: () -> Unit,
Expand All @@ -603,24 +738,23 @@ private fun MessageInput(
val isOverLimit = currentByteLength > maxByteSize
val canSend = !isOverLimit && currentText.isNotEmpty() && isEnabled

val nodesById = remember(nodes) { nodes.associateBy { it.user.id } }
val mentionOutput = remember(nodesById) { mentionOutputTransformation(nodesById) }
val mentionOutput = remember(mentionCandidates) { mentionOutputTransformation(mentionCandidates) }
val mentionQuery by
remember(textFieldState) {
derivedStateOf { currentMentionQuery(textFieldState.text.toString(), textFieldState.selection) }
}
val suggestions =
remember(mentionQuery, nodes) {
val query = mentionQuery?.query ?: return@remember emptyList<Node>()
nodes.filter { it.matchesMention(query) }.take(MENTION_SUGGESTION_LIMIT)
remember(mentionQuery, mentionCandidates) {
val query = mentionQuery?.query ?: return@remember emptyList<MentionCandidate>()
matchingMentionCandidates(mentionCandidates.values, query)
}

// While the mention popup is open, Enter / send completes the top suggestion instead of sending a raw @query.
val mentionActive = mentionQuery != null && suggestions.isNotEmpty()
fun insertMention(node: Node) {
fun insertMention(candidate: MentionCandidate) {
val q = mentionQuery ?: return
textFieldState.edit {
val insert = "@${node.user.id} "
val insert = "@${candidate.id} "
replace(q.start, q.end, insert)
selection = TextRange(q.start + insert.length)
}
Expand Down Expand Up @@ -698,27 +832,29 @@ private fun MessageInput(
}

@Composable
private fun MentionSuggestions(suggestions: List<Node>, onPick: (Node) -> Unit) {
private fun MentionSuggestions(suggestions: List<MentionCandidate>, onPick: (MentionCandidate) -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
tonalElevation = 3.dp,
shape = RoundedCornerShape(12.dp),
) {
Column {
suggestions.forEach { node ->
suggestions.forEach { candidate ->
Row(
modifier =
Modifier.fillMaxWidth().clickable { onPick(node) }.padding(horizontal = 12.dp, vertical = 8.dp),
Modifier.fillMaxWidth()
.clickable { onPick(candidate) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = node.user.short_name,
text = candidate.shortName,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
)
Text(
text = node.user.long_name,
text = candidate.longName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
Expand All @@ -732,29 +868,30 @@ private fun MentionSuggestions(suggestions: List<Node>, onPick: (Node) -> Unit)
@PreviewLightDark
@Composable
fun MessageInputPreview() {
val mentionCandidates = persistentMapOf<String, MentionCandidate>()
AppTheme {
Surface {
Column(modifier = Modifier.padding(8.dp)) {
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
nodes = emptyList(),
mentionCandidates = mentionCandidates,
textFieldState = rememberTextFieldState("Hello"),
onSendMessage = {},
)
Spacer(Modifier.size(16.dp))
MessageInput(
isEnabled = false,
isHomoglyphEncodingEnabled = false,
nodes = emptyList(),
mentionCandidates = mentionCandidates,
textFieldState = rememberTextFieldState("Disabled"),
onSendMessage = {},
)
Spacer(Modifier.size(16.dp))
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
nodes = emptyList(),
mentionCandidates = mentionCandidates,
textFieldState =
rememberTextFieldState(
"A very long message that might exceed the byte limit " +
Expand All @@ -768,7 +905,7 @@ fun MessageInputPreview() {
MessageInput(
isEnabled = true,
isHomoglyphEncodingEnabled = false,
nodes = emptyList(),
mentionCandidates = mentionCandidates,
textFieldState = rememberTextFieldState("こんにちは世界"), // Hello World in Japanese
onSendMessage = {},
maxByteSize = 10,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -34,6 +36,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.currentLocaleCode
import org.meshtastic.core.common.util.ioDispatcher
Expand Down Expand Up @@ -108,13 +111,28 @@ class MessageViewModel(
private val _draftMessage = MutableStateFlow(savedStateHandle.get<String>("draftMessage") ?: "")
val draftMessage: StateFlow<String> = _draftMessage.asStateFlow()

private var pendingDraftPersistence: Job? = null

/**
* Updates the in-memory draft immediately. The durable [SavedStateHandle] write is debounced by
* [DRAFT_PERSISTENCE_DELAY_MS] — rapid edits cancel the prior pending flush, so only the trailing value persists.
* On process death within the debounce window the last ≤[DRAFT_PERSISTENCE_DELAY_MS] of typing is not durably
* saved; this is a conscious trade-off to avoid per-keystroke jank.
*/
fun setDraftMessage(text: String) {
_draftMessage.value = text
savedStateHandle["draftMessage"] = text
pendingDraftPersistence?.cancel()
pendingDraftPersistence =
viewModelScope.launch {
delay(DRAFT_PERSISTENCE_DELAY_MS)
savedStateHandle["draftMessage"] = text
}
}

fun clearDraftMessage() {
_draftMessage.value = ""
pendingDraftPersistence?.cancel()
pendingDraftPersistence = null
savedStateHandle["draftMessage"] = ""
}

Expand Down Expand Up @@ -413,5 +431,6 @@ class MessageViewModel(
companion object {
private const val SEARCH_DEBOUNCE_MS = 300L
private const val MIN_SEARCH_LENGTH = 2
private const val DRAFT_PERSISTENCE_DELAY_MS = 300L
}
}
Loading