mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 17:45:20 +00:00
Feature/mentions (#197)
* mention initial implementation * Message conservation
This commit is contained in:
@@ -64,6 +64,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
val showSidebar by viewModel.showSidebar.observeAsState(false)
|
val showSidebar by viewModel.showSidebar.observeAsState(false)
|
||||||
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
|
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
|
||||||
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
|
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)
|
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
|
||||||
|
|
||||||
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
@@ -114,6 +116,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
onMessageTextChange = { newText: TextFieldValue ->
|
onMessageTextChange = { newText: TextFieldValue ->
|
||||||
messageText = newText
|
messageText = newText
|
||||||
viewModel.updateCommandSuggestions(newText.text)
|
viewModel.updateCommandSuggestions(newText.text)
|
||||||
|
viewModel.updateMentionSuggestions(newText.text)
|
||||||
},
|
},
|
||||||
onSend = {
|
onSend = {
|
||||||
if (messageText.text.trim().isNotEmpty()) {
|
if (messageText.text.trim().isNotEmpty()) {
|
||||||
@@ -123,13 +126,22 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
},
|
},
|
||||||
showCommandSuggestions = showCommandSuggestions,
|
showCommandSuggestions = showCommandSuggestions,
|
||||||
commandSuggestions = commandSuggestions,
|
commandSuggestions = commandSuggestions,
|
||||||
onSuggestionClick = { suggestion: CommandSuggestion ->
|
showMentionSuggestions = showMentionSuggestions,
|
||||||
|
mentionSuggestions = mentionSuggestions,
|
||||||
|
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
|
||||||
val commandText = viewModel.selectCommandSuggestion(suggestion)
|
val commandText = viewModel.selectCommandSuggestion(suggestion)
|
||||||
messageText = TextFieldValue(
|
messageText = TextFieldValue(
|
||||||
text = commandText,
|
text = commandText,
|
||||||
selection = TextRange(commandText.length)
|
selection = TextRange(commandText.length)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onMentionSuggestionClick = { mention: String ->
|
||||||
|
val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text)
|
||||||
|
messageText = TextFieldValue(
|
||||||
|
text = mentionText,
|
||||||
|
selection = TextRange(mentionText.length)
|
||||||
|
)
|
||||||
|
},
|
||||||
selectedPrivatePeer = selectedPrivatePeer,
|
selectedPrivatePeer = selectedPrivatePeer,
|
||||||
currentChannel = currentChannel,
|
currentChannel = currentChannel,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
@@ -202,7 +214,10 @@ private fun ChatInputSection(
|
|||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
showCommandSuggestions: Boolean,
|
showCommandSuggestions: Boolean,
|
||||||
commandSuggestions: List<CommandSuggestion>,
|
commandSuggestions: List<CommandSuggestion>,
|
||||||
onSuggestionClick: (CommandSuggestion) -> Unit,
|
showMentionSuggestions: Boolean,
|
||||||
|
mentionSuggestions: List<String>,
|
||||||
|
onCommandSuggestionClick: (CommandSuggestion) -> Unit,
|
||||||
|
onMentionSuggestionClick: (String) -> Unit,
|
||||||
selectedPrivatePeer: String?,
|
selectedPrivatePeer: String?,
|
||||||
currentChannel: String?,
|
currentChannel: String?,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -220,7 +235,18 @@ private fun ChatInputSection(
|
|||||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||||
CommandSuggestionsBox(
|
CommandSuggestionsBox(
|
||||||
suggestions = commandSuggestions,
|
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()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ class ChatState {
|
|||||||
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
|
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
|
||||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
|
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
|
// Favorites
|
||||||
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
|
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
|
||||||
val favoritePeers: LiveData<Set<String>> = _favoritePeers
|
val favoritePeers: LiveData<Set<String>> = _favoritePeers
|
||||||
@@ -129,6 +136,8 @@ class ChatState {
|
|||||||
fun getShowSidebarValue() = _showSidebar.value ?: false
|
fun getShowSidebarValue() = _showSidebar.value ?: false
|
||||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
|
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
|
||||||
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
|
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
|
||||||
|
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false
|
||||||
|
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList()
|
||||||
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
|
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
|
||||||
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
|
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
|
||||||
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
|
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
|
||||||
@@ -202,6 +211,14 @@ class ChatState {
|
|||||||
fun setCommandSuggestions(suggestions: List<CommandSuggestion>) {
|
fun setCommandSuggestions(suggestions: List<CommandSuggestion>) {
|
||||||
_commandSuggestions.value = suggestions
|
_commandSuggestions.value = suggestions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowMentionSuggestions(show: Boolean) {
|
||||||
|
_showMentionSuggestions.value = show
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMentionSuggestions(suggestions: List<String>) {
|
||||||
|
_mentionSuggestions.value = suggestions
|
||||||
|
}
|
||||||
|
|
||||||
fun setFavoritePeers(favorites: Set<String>) {
|
fun setFavoritePeers(favorites: Set<String>) {
|
||||||
val currentValue = _favoritePeers.value ?: emptySet()
|
val currentValue = _favoritePeers.value ?: emptySet()
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ class ChatViewModel(
|
|||||||
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
||||||
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
|
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
|
||||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
|
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 favoritePeers: LiveData<Set<String>> = state.favoritePeers
|
||||||
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
|
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
|
||||||
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
|
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
|
||||||
@@ -351,6 +353,16 @@ class ChatViewModel(
|
|||||||
return commandProcessor.selectCommandSuggestion(suggestion)
|
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)
|
// MARK: - BluetoothMeshDelegate Implementation (delegated)
|
||||||
|
|
||||||
override fun didReceiveMessage(message: BitchatMessage) {
|
override fun didReceiveMessage(message: BitchatMessage) {
|
||||||
|
|||||||
@@ -380,6 +380,65 @@ class CommandProcessor(
|
|||||||
return "${suggestion.command} "
|
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)
|
// MARK: - Utility Functions (would access mesh service)
|
||||||
|
|
||||||
private fun getPeerIDForNickname(nickname: String, meshService: Any): String? {
|
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 = {
|
keyboardActions = KeyboardActions(onSend = {
|
||||||
if (hasText) onSend() // Only send if there's text
|
if (hasText) onSend() // Only send if there's text
|
||||||
}),
|
}),
|
||||||
visualTransformation = SlashCommandVisualTransformation(),
|
visualTransformation = CombinedVisualTransformation(
|
||||||
|
listOf(SlashCommandVisualTransformation(), MentionVisualTransformation())
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onFocusChanged { focusState ->
|
.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user