Feature/mentions (#197)

* mention initial implementation

* Message conservation
This commit is contained in:
Francisco Hola
2025-08-05 23:32:16 +02:00
committed by GitHub
parent 7ee0e8f2f4
commit 325ce1730d
5 changed files with 243 additions and 4 deletions
@@ -64,6 +64,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
val showSidebar by viewModel.showSidebar.observeAsState(false)
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false)
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
var messageText by remember { mutableStateOf(TextFieldValue("")) }
@@ -114,6 +116,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
onMessageTextChange = { newText: TextFieldValue ->
messageText = newText
viewModel.updateCommandSuggestions(newText.text)
viewModel.updateMentionSuggestions(newText.text)
},
onSend = {
if (messageText.text.trim().isNotEmpty()) {
@@ -123,13 +126,22 @@ fun ChatScreen(viewModel: ChatViewModel) {
},
showCommandSuggestions = showCommandSuggestions,
commandSuggestions = commandSuggestions,
onSuggestionClick = { suggestion: CommandSuggestion ->
showMentionSuggestions = showMentionSuggestions,
mentionSuggestions = mentionSuggestions,
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
val commandText = viewModel.selectCommandSuggestion(suggestion)
messageText = TextFieldValue(
text = commandText,
selection = TextRange(commandText.length)
)
},
onMentionSuggestionClick = { mention: String ->
val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text)
messageText = TextFieldValue(
text = mentionText,
selection = TextRange(mentionText.length)
)
},
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
@@ -202,7 +214,10 @@ private fun ChatInputSection(
onSend: () -> Unit,
showCommandSuggestions: Boolean,
commandSuggestions: List<CommandSuggestion>,
onSuggestionClick: (CommandSuggestion) -> Unit,
showMentionSuggestions: Boolean,
mentionSuggestions: List<String>,
onCommandSuggestionClick: (CommandSuggestion) -> Unit,
onMentionSuggestionClick: (String) -> Unit,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
@@ -220,7 +235,18 @@ private fun ChatInputSection(
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
CommandSuggestionsBox(
suggestions = commandSuggestions,
onSuggestionClick = onSuggestionClick,
onSuggestionClick = onCommandSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
}
// Mention suggestions box
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
MentionSuggestionsBox(
suggestions = mentionSuggestions,
onSuggestionClick = onMentionSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
@@ -79,6 +79,13 @@ class ChatState {
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
// Mention autocomplete
private val _showMentionSuggestions = MutableLiveData(false)
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList())
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions
// Favorites
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
val favoritePeers: LiveData<Set<String>> = _favoritePeers
@@ -129,6 +136,8 @@ class ChatState {
fun getShowSidebarValue() = _showSidebar.value ?: false
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList()
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
@@ -202,6 +211,14 @@ class ChatState {
fun setCommandSuggestions(suggestions: List<CommandSuggestion>) {
_commandSuggestions.value = suggestions
}
fun setShowMentionSuggestions(show: Boolean) {
_showMentionSuggestions.value = show
}
fun setMentionSuggestions(suggestions: List<String>) {
_mentionSuggestions.value = suggestions
}
fun setFavoritePeers(favorites: Set<String>) {
val currentValue = _favoritePeers.value ?: emptySet()
@@ -83,6 +83,8 @@ class ChatViewModel(
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
@@ -351,6 +353,16 @@ class ChatViewModel(
return commandProcessor.selectCommandSuggestion(suggestion)
}
// MARK: - Mention Autocomplete
fun updateMentionSuggestions(input: String) {
commandProcessor.updateMentionSuggestions(input, meshService)
}
fun selectMentionSuggestion(nickname: String, currentText: String): String {
return commandProcessor.selectMentionSuggestion(nickname, currentText)
}
// MARK: - BluetoothMeshDelegate Implementation (delegated)
override fun didReceiveMessage(message: BitchatMessage) {
@@ -380,6 +380,65 @@ class CommandProcessor(
return "${suggestion.command} "
}
// MARK: - Mention Autocomplete
fun updateMentionSuggestions(input: String, meshService: Any) {
// Check if input contains @ and we're at the end of a word or at the end of input
val atIndex = input.lastIndexOf('@')
if (atIndex == -1) {
state.setShowMentionSuggestions(false)
state.setMentionSuggestions(emptyList())
return
}
// Get the text after the @ symbol
val textAfterAt = input.substring(atIndex + 1)
// If there's a space after @, don't show suggestions
if (textAfterAt.contains(' ')) {
state.setShowMentionSuggestions(false)
state.setMentionSuggestions(emptyList())
return
}
// Get all connected peer nicknames
val peerNicknames = try {
val method = meshService::class.java.getDeclaredMethod("getPeerNicknames")
val peerNicknamesMap = method.invoke(meshService) as? Map<String, String>
peerNicknamesMap?.values?.toList() ?: emptyList()
} catch (e: Exception) {
emptyList()
}
// Filter nicknames based on the text after @
val filteredNicknames = peerNicknames.filter { nickname ->
nickname.startsWith(textAfterAt, ignoreCase = true)
}.sorted()
if (filteredNicknames.isNotEmpty()) {
state.setMentionSuggestions(filteredNicknames)
state.setShowMentionSuggestions(true)
} else {
state.setShowMentionSuggestions(false)
state.setMentionSuggestions(emptyList())
}
}
fun selectMentionSuggestion(nickname: String, currentText: String): String {
state.setShowMentionSuggestions(false)
state.setMentionSuggestions(emptyList())
// Find the last @ symbol position
val atIndex = currentText.lastIndexOf('@')
if (atIndex == -1) {
return "$currentText@$nickname "
}
// Replace the text from the @ symbol to the end with the mention
val textBeforeAt = currentText.substring(0, atIndex)
return "$textBeforeAt@$nickname "
}
// MARK: - Utility Functions (would access mesh service)
private fun getPeerIDForNickname(nickname: String, meshService: Any): String? {
@@ -80,6 +80,68 @@ class SlashCommandVisualTransformation : VisualTransformation {
}
}
/**
* VisualTransformation that styles mentions with background and color
* while preserving cursor positioning and click handling
*/
class MentionVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val mentionRegex = Regex("@([a-zA-Z0-9_]+)")
val annotatedString = buildAnnotatedString {
var lastIndex = 0
mentionRegex.findAll(text.text).forEach { match ->
// Add text before the match
if (match.range.first > lastIndex) {
append(text.text.substring(lastIndex, match.range.first))
}
// Add the styled mention
withStyle(
style = SpanStyle(
color = Color(0xFFFF9500), // Orange
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold
)
) {
append(match.value)
}
lastIndex = match.range.last + 1
}
// Add remaining text
if (lastIndex < text.text.length) {
append(text.text.substring(lastIndex))
}
}
return TransformedText(
text = annotatedString,
offsetMapping = OffsetMapping.Identity
)
}
}
/**
* VisualTransformation that combines multiple visual transformations
*/
class CombinedVisualTransformation(private val transformations: List<VisualTransformation>) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
var resultText = text
// Apply each transformation in order
transformations.forEach { transformation ->
resultText = transformation.filter(resultText).text
}
return TransformedText(
text = resultText,
offsetMapping = OffsetMapping.Identity
)
}
}
@@ -118,7 +180,9 @@ fun MessageInput(
keyboardActions = KeyboardActions(onSend = {
if (hasText) onSend() // Only send if there's text
}),
visualTransformation = SlashCommandVisualTransformation(),
visualTransformation = CombinedVisualTransformation(
listOf(SlashCommandVisualTransformation(), MentionVisualTransformation())
),
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
@@ -270,3 +334,64 @@ fun CommandSuggestionItem(
)
}
}
@Composable
fun MentionSuggestionsBox(
suggestions: List<String>,
onSuggestionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Column(
modifier = modifier
.background(colorScheme.surface)
.border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
.padding(vertical = 8.dp)
) {
suggestions.forEach { suggestion: String ->
MentionSuggestionItem(
suggestion = suggestion,
onClick = { onSuggestionClick(suggestion) }
)
}
}
}
@Composable
fun MentionSuggestionItem(
suggestion: String,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color.Gray.copy(alpha = 0.1f)),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "@$suggestion",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold
),
color = Color(0xFFFF9500), // Orange like mentions
fontSize = 11.sp
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "mention",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontSize = 10.sp
)
}
}