diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index c4dd311c..1aeefc7d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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, - onSuggestionClick: (CommandSuggestion) -> Unit, + showMentionSuggestions: Boolean, + mentionSuggestions: List, + 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() ) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index a5a6e17a..b01a7e67 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -79,6 +79,13 @@ class ChatState { private val _commandSuggestions = MutableLiveData>(emptyList()) val commandSuggestions: LiveData> = _commandSuggestions + // Mention autocomplete + private val _showMentionSuggestions = MutableLiveData(false) + val showMentionSuggestions: LiveData = _showMentionSuggestions + + private val _mentionSuggestions = MutableLiveData>(emptyList()) + val mentionSuggestions: LiveData> = _mentionSuggestions + // Favorites private val _favoritePeers = MutableLiveData>(emptySet()) val favoritePeers: LiveData> = _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) { _commandSuggestions.value = suggestions } + + fun setShowMentionSuggestions(show: Boolean) { + _showMentionSuggestions.value = show + } + + fun setMentionSuggestions(suggestions: List) { + _mentionSuggestions.value = suggestions + } fun setFavoritePeers(favorites: Set) { val currentValue = _favoritePeers.value ?: emptySet() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index ea9f3b5a..f61fe0f5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -83,6 +83,8 @@ class ChatViewModel( val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages val showCommandSuggestions: LiveData = state.showCommandSuggestions val commandSuggestions: LiveData> = state.commandSuggestions + val showMentionSuggestions: LiveData = state.showMentionSuggestions + val mentionSuggestions: LiveData> = state.mentionSuggestions val favoritePeers: LiveData> = state.favoritePeers val peerSessionStates: LiveData> = state.peerSessionStates val peerFingerprints: LiveData> = 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) { 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 bec01eec..81c77c3b 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -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 + 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? { 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 0489930e..6be2507c 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -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 { + 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, + 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 + ) + } +}