From 4863381dbb7c600c8d8fe471ed4857a7c3414505 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:27:15 +0200 Subject: [PATCH] mentions --- .../bitchat/android/ui/CommandProcessor.kt | 38 ++++++++++--- .../com/bitchat/android/ui/InputComponents.kt | 37 +++++++++---- .../android/ui/MentionSuggestionsTest.kt | 54 +++++++++++++++++++ 3 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index a92d2d60..04c0b0d7 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date +import java.util.Locale /** * Handles processing of IRC-style commands @@ -448,12 +449,13 @@ class CommandProcessor( is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: use Bluetooth mesh peer nicknames - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } is com.bitchat.android.geohash.ChannelID.Location -> { // Location channel: use geohash participants with collision-resistant suffixes - val geohashPeople = viewModel.geohashPeople.value ?: emptyList() + val geohashPeople = viewModel.geohashPeople.value val currentNickname = state.getNicknameValue() geohashPeople.mapNotNull { person -> @@ -469,13 +471,11 @@ class CommandProcessor( } } else { // Fallback to mesh peers if no viewModel available - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } - // Filter nicknames based on the text after @ - val filteredNicknames = peerCandidates.filter { nickname -> - nickname.startsWith(textAfterAt, ignoreCase = true) - }.sorted() + val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt) if (filteredNicknames.isNotEmpty()) { state.setMentionSuggestions(filteredNicknames) @@ -533,3 +533,27 @@ class CommandProcessor( } } } + +/** + * Keep mention autocomplete useful in crowded channels: a bare `anon` identity has not announced + * a username and is not actionable. Names such as `anon1234` are announced usernames and remain + * valid mention targets. + */ +internal fun filterMentionCandidates( + candidates: List, + query: String +): List { + return candidates.asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .filterNot(::isUnannouncedMentionNickname) + .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } + .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } + .sortedWith(String.CASE_INSENSITIVE_ORDER) + .toList() +} + +internal fun isUnannouncedMentionNickname(displayName: String): Boolean { + val base = splitSuffix(displayName.trim()).first + return base.equals("anon", ignoreCase = true) +} diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 7b556fb9..d0377a8f 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,6 +1,5 @@ package com.bitchat.android.ui -import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher @@ -28,6 +27,8 @@ import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -707,13 +708,18 @@ fun MentionSuggestionsBox( ) { val palette = LocalBitchatPalette.current - Column( + LazyColumn( modifier = modifier + .heightIn(max = MentionSuggestionsMaxHeight) + .clip(MentionSuggestionsShape) .background(palette.surface) - .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) - .padding(vertical = 6.dp) + .border(1.dp, palette.outlineVariant, MentionSuggestionsShape), + contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { - suggestions.forEach { suggestion: String -> + items( + items = suggestions, + key = { suggestion -> suggestion.lowercase() } + ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, onClick = { onSuggestionClick(suggestion) } @@ -732,8 +738,9 @@ fun MentionSuggestionItem( Row( modifier = Modifier .fillMaxWidth() + .height(MentionSuggestionRowHeight) .clickable { onClick() } - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -743,10 +750,13 @@ fun MentionSuggestionItem( fontWeight = FontWeight.SemiBold ), color = palette.accentOrange, - fontSize = (BASE_FONT_SIZE - 2).sp + fontSize = (BASE_FONT_SIZE - 2).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(12.dp)) Text( text = stringResource(R.string.mention), @@ -754,7 +764,16 @@ fun MentionSuggestionItem( fontFamily = BitchatFontFamily ), color = palette.textTertiary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 4).sp, + maxLines = 1 ) } } + +/** Mention autocomplete stays compact even in crowded channels. */ +internal const val MaxVisibleMentionSuggestions = 5 +private val MentionSuggestionRowHeight = 48.dp +private val MentionSuggestionsVerticalPadding = 6.dp +private val MentionSuggestionsMaxHeight = + (48 * MaxVisibleMentionSuggestions + 12).dp +private val MentionSuggestionsShape = RoundedCornerShape(8.dp) diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt new file mode 100644 index 00000000..75fb6c01 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MentionSuggestionsTest { + + @Test + fun `only users without an announced nickname are excluded from mentions`() { + val suggestions = filterMentionCandidates( + candidates = listOf( + "anon", + "anon#04af", + "anon7674#df5b", + "alice#1234", + "anonymous", + "anonracer#04af" + ), + query = "" + ) + + assertEquals( + listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"), + suggestions + ) + assertTrue(suggestions.none(::isUnannouncedMentionNickname)) + } + + @Test + fun `mention filtering is case insensitive and removes duplicates`() { + val suggestions = filterMentionCandidates( + candidates = listOf("Bob#1234", "bob#1234", "bobby#5678", "alice#9999"), + query = "BO" + ) + + assertEquals(listOf("Bob#1234", "bobby#5678"), suggestions) + } + + @Test + fun `announced names beginning with anon stay mentionable`() { + assertTrue(isUnannouncedMentionNickname("anon")) + assertTrue(isUnannouncedMentionNickname("anon#04af")) + assertFalse(isUnannouncedMentionNickname("anon1234#04af")) + assertFalse(isUnannouncedMentionNickname("anonymous#04af")) + assertFalse(isUnannouncedMentionNickname("anonracer")) + } + + @Test + fun `mention popup viewport is capped at five rows`() { + assertEquals(5, MaxVisibleMentionSuggestions) + } +}