feat(bots): add horizontal @ mention suggestion chip bar in group chat (#1048)
This commit is contained in:
2 files changed
+211
-20
No files matched your search
@@ -2,6 +2,11 @@ package com.m57.hermescontrol.ui.bots.group
|
|||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -18,6 +23,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -31,6 +37,7 @@ import androidx.compose.material.icons.filled.Build
|
|||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Description
|
import androidx.compose.material.icons.filled.Description
|
||||||
|
import androidx.compose.material.icons.filled.Groups
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
@@ -44,6 +51,7 @@ import androidx.compose.material3.IconButtonDefaults
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SuggestionChip
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -65,17 +73,21 @@ import androidx.compose.ui.platform.ClipEntry
|
|||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.theme.LocalChatFontScale
|
import com.m57.hermescontrol.theme.LocalChatFontScale
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
import com.m57.hermescontrol.ui.chat.MarkdownText
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
@@ -85,6 +97,7 @@ import com.m57.hermescontrol.ui.common.BotAvatar
|
|||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -283,6 +296,111 @@ fun GroupChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MENTION SUGGESTION CHIP BAR (Horizontal scrollable chips) ──
|
||||||
|
val mentionQuery =
|
||||||
|
remember(inputFieldValue.text, inputFieldValue.selection.end) {
|
||||||
|
ChatInputPolicy.extractMentionQuery(
|
||||||
|
inputFieldValue.text,
|
||||||
|
inputFieldValue.selection.end,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val groupBots =
|
||||||
|
remember(state.members) {
|
||||||
|
state.members.distinctBy { it.name.lowercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val showAllOption =
|
||||||
|
remember(mentionQuery, groupBots) {
|
||||||
|
if (mentionQuery == null || groupBots.isEmpty()) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
"all".startsWith(mentionQuery, ignoreCase = true) ||
|
||||||
|
"everyone".startsWith(mentionQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filteredBots =
|
||||||
|
remember(mentionQuery, groupBots) {
|
||||||
|
if (mentionQuery == null) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
groupBots.filter { bot ->
|
||||||
|
bot.name.startsWith(mentionQuery, ignoreCase = true) ||
|
||||||
|
bot.effectiveTitle.contains(mentionQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = mentionQuery != null && (showAllOption || filteredBots.isNotEmpty()),
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically(),
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||||
|
.testTag("group_chat_mention_row"),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (showAllOption) {
|
||||||
|
item(key = "mention_all") {
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {
|
||||||
|
inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, "all")
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = "@all",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Groups,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.testTag("mention_chip_all"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(filteredBots, key = { it.name }) { bot ->
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {
|
||||||
|
inputFieldValue = ChatInputPolicy.applyMention(inputFieldValue, bot.name)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = "@${bot.name}",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
BotAvatar(
|
||||||
|
name = bot.name,
|
||||||
|
avatar = bot.botMeta()?.avatar,
|
||||||
|
size = 18.dp,
|
||||||
|
showPresence = false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.testTag("mention_chip_${bot.name}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ──
|
// ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ──
|
||||||
Surface(
|
Surface(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -309,7 +427,23 @@ fun GroupChatScreen(
|
|||||||
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
val isInputRtl = remember(inputFieldValue.text) { BidiUtils.isRtlText(inputFieldValue.text) }
|
||||||
|
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||||
|
val inputLayoutDirection =
|
||||||
|
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||||
|
if (inputFieldValue.text.isBlank()) {
|
||||||
|
ambientLayoutDirection
|
||||||
|
} else if (isInputRtl) {
|
||||||
|
LayoutDirection.Rtl
|
||||||
|
} else {
|
||||||
|
LayoutDirection.Ltr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = if (isInputRtl) Alignment.CenterEnd else Alignment.CenterStart,
|
||||||
|
) {
|
||||||
if (inputFieldValue.text.isEmpty()) {
|
if (inputFieldValue.text.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
@@ -319,27 +453,32 @@ fun GroupChatScreen(
|
|||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||||
|
BasicTextField(
|
||||||
|
value = inputFieldValue,
|
||||||
|
onValueChange = { inputFieldValue = it },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 24.dp, max = 120.dp)
|
||||||
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
|
.testTag("group_chat_input"),
|
||||||
|
textStyle =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
|
),
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = 4,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = { handleSend() }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BasicTextField(
|
|
||||||
value = inputFieldValue,
|
|
||||||
onValueChange = { inputFieldValue = it },
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 24.dp, max = 120.dp)
|
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
|
||||||
.testTag("group_chat_input"),
|
|
||||||
textStyle =
|
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
singleLine = false,
|
|
||||||
maxLines = 4,
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
|
||||||
keyboardActions = KeyboardActions(onSend = { handleSend() }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|||||||
@@ -68,4 +68,56 @@ class GroupChatMentionsTest {
|
|||||||
assertEquals(1, responders.size)
|
assertEquals(1, responders.size)
|
||||||
assertEquals("coder", responders.first().name)
|
assertEquals("coder", responders.first().name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupChatMentionAutocomplete_endToEndIntegration() {
|
||||||
|
// 1. User types "@cod"
|
||||||
|
val input =
|
||||||
|
androidx.compose.ui.text.input
|
||||||
|
.TextFieldValue(
|
||||||
|
"hey @cod",
|
||||||
|
androidx.compose.ui.text
|
||||||
|
.TextRange(8),
|
||||||
|
)
|
||||||
|
val query =
|
||||||
|
com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
|
.extractMentionQuery(input.text, input.selection.end)
|
||||||
|
assertEquals("cod", query)
|
||||||
|
|
||||||
|
// 2. Candidate filtering:
|
||||||
|
// Query "@c" matches both "coder" (starts with c) and "Scout Bot" (contains c)
|
||||||
|
val broadMatches =
|
||||||
|
members.filter {
|
||||||
|
it.name.startsWith("c", ignoreCase = true) || it.effectiveTitle.contains("c", ignoreCase = true)
|
||||||
|
}
|
||||||
|
assertEquals(2, broadMatches.size)
|
||||||
|
|
||||||
|
// Narrower query "@cod" matches only "coder"
|
||||||
|
val matchingBots =
|
||||||
|
members.filter {
|
||||||
|
it.name.startsWith(query!!, ignoreCase = true) || it.effectiveTitle.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
assertEquals(1, matchingBots.size)
|
||||||
|
assertEquals("coder", matchingBots.first().name)
|
||||||
|
|
||||||
|
// 3. User taps chip for coder
|
||||||
|
val completed =
|
||||||
|
com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
|
.applyMention(input, matchingBots.first().name)
|
||||||
|
assertEquals("hey @coder ", completed.text)
|
||||||
|
assertEquals(11, completed.selection.end)
|
||||||
|
|
||||||
|
// 4. Mention query is now null after space
|
||||||
|
val nextQuery =
|
||||||
|
com.m57.hermescontrol.ui.chat.ChatInputPolicy.extractMentionQuery(
|
||||||
|
completed.text,
|
||||||
|
completed.selection.end,
|
||||||
|
)
|
||||||
|
org.junit.Assert.assertNull(nextQuery)
|
||||||
|
|
||||||
|
// 5. Group chat mentions resolver picks up the mentioned bot
|
||||||
|
val responders = GroupChatMentions.resolveResponders(completed.text, members)
|
||||||
|
assertEquals(1, responders.size)
|
||||||
|
assertEquals("coder", responders.first().name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user