2026-07-27 16:33:05 +02:00

836 lines
34 KiB
Kotlin

package com.bitchat.android.ui
import com.bitchat.android.ui.theme.BitchatFontFamily
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.expandVertically
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
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
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.withStyle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.ui.theme.colorForPeer
import com.bitchat.android.features.voice.normalizeAmplitudeSample
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
import com.bitchat.android.ui.media.ImagePickerButton
import com.bitchat.android.ui.media.FilePickerButton
/**
* Input components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
/**
* VisualTransformation that styles slash commands with background and color
* while preserving cursor positioning and click handling
*/
class SlashCommandVisualTransformation(
private val commandColor: Color,
private val commandBackground: Color,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)")
val annotatedString = buildAnnotatedString {
var lastIndex = 0
slashCommandRegex.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 slash command
withStyle(
style = SpanStyle(
color = commandColor,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
background = commandBackground
)
) {
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 styles mentions with background and color
* while preserving cursor positioning and click handling
*/
class MentionVisualTransformation(
private val mentionColor: Color,
private val mentionBackground: Color,
) : 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 = mentionColor,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
// Mirrors the mention chip used in rendered messages, so what you type
// looks like what everyone will see.
background = mentionBackground
)
) {
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
)
}
}
/**
* Minimum height of the composer pill.
*
* Roomy on purpose. The composer is a primary target that gets hit constantly, and the previous
* 44.dp felt cramped once the action buttons moved inside it.
*/
private val ComposerMinHeight = 52.dp
/**
* Composer corner radius.
*
* Half of [ComposerMinHeight], so a single-line composer is a true capsule. Fixed rather than
* percentage-based so that when the field grows to several lines it stays a generously rounded
* rectangle instead of degenerating into a stadium.
*/
private val ComposerShape = RoundedCornerShape(ComposerMinHeight / 2)
/** Tap target for every button inside the pill. */
private val ComposerButtonSize = 40.dp
/** Diameter of the visible disc inside that tap target. */
private val ComposerButtonDisc = 36.dp
/** Icon size shared by the composer's glyphs. */
internal val ComposerIconSize = 20.dp
/**
* Opacity of the composer pill.
*
* Not fully opaque: the message list scrolls underneath the composer, and letting a hint of it
* through is what makes the bar read as sitting *over* the conversation rather than boxing it in.
* Kept high enough that text in the field never loses contrast.
*/
private const val ComposerFillAlpha = 0.88f
/**
* The shared visual treatment for every button in the composer: camera, microphone, send.
*
* One style for all three, so the cluster reads as a set. At rest they are neutral grey discs;
* "active" (send with something to send, microphone while recording) tints towards a soft green
* rather than the terminal's full-brightness primary, which was far too loud sitting right next
* to the text you are typing.
*
* The caller owns the gesture, because the three buttons need very different ones (click,
* long-press for the camera, press-and-hold for the microphone). This composable only supplies
* the geometry, the colours, and the press feedback.
*/
@Composable
internal fun ComposerActionSurface(
isActive: Boolean,
modifier: Modifier = Modifier,
isPressed: Boolean = false,
/** Accent for the active state. Defaults to the soft green used by the microphone and send. */
activeColor: Color = Color.Unspecified,
contentDescription: String? = null,
content: @Composable (tint: Color) -> Unit
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val accent = if (activeColor == Color.Unspecified) colorScheme.primary else activeColor
val container by animateColorAsState(
// A tint rather than a fill. A solid accent disc next to the text you are typing was the
// loudest thing on the screen; at 20% it still reads as "armed" without competing.
targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerButtonContainer"
)
val tint by animateColorAsState(
targetValue = if (isActive) accent else colorScheme.onSurfaceVariant,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerButtonTint"
)
// A small dip on press. Spring rather than tween so the release overshoots very slightly and
// the button feels physical instead of merely animated.
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.88f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "composerButtonScale"
)
Box(
modifier = modifier.size(ComposerButtonSize),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(ComposerButtonDisc)
.scale(scale)
.background(container, CircleShape)
.semantics { contentDescription?.let { this.contentDescription = it } },
contentAlignment = Alignment.Center
) {
content(tint)
}
}
}
@Composable
fun MessageInput(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onSendVoiceNote: (String?, String?, String) -> Unit,
onSendImageNote: (String?, String?, String) -> Unit,
onSendFileNote: (String?, String?, String) -> Unit,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
showMediaButtons: Boolean,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val isFocused = remember { mutableStateOf(false) }
val hasText = value.text.isNotBlank()
val focusRequester = remember { FocusRequester() }
var isRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
// Recording is the one state worth shouting about, so it overrides focus.
val borderColor by animateColorAsState(
targetValue = when {
isRecording -> colorScheme.error.copy(alpha = 0.7f)
isFocused.value -> palette.inputOutlineFocused
else -> palette.inputOutline
},
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerBorder"
)
// A barely-there lift on focus. Enough to register, not enough to look like a different
// component. Slightly translucent so the messages scrolling underneath stay faintly visible.
val containerColor by animateColorAsState(
targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface)
.copy(alpha = ComposerFillAlpha),
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerContainer"
)
Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom
) {
// MARK: - The pill. Field and action buttons are one visual object.
Row(
modifier = Modifier
.weight(1f)
.heightIn(min = ComposerMinHeight)
// Grow smoothly as the field wraps to more lines rather than jumping a line at
// a time.
.animateContentSize(
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
)
.background(containerColor, ComposerShape)
.border(1.dp, borderColor, ComposerShape),
verticalAlignment = Alignment.Bottom
) {
Box(
modifier = Modifier
.weight(1f)
.padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp)
) {
// Always keep the text field mounted to retain focus and avoid IME collapse
BasicTextField(
value = value,
onValueChange = onValueChange,
// Near-white, not terminal green: this is the one place in the app where the
// user is composing rather than reading, and green-on-black is tiring to
// type into.
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.onSurface,
fontFamily = BitchatFontFamily
),
cursorBrush = SolidColor(
if (isRecording) Color.Transparent else colorScheme.onSurface
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (hasText) onSend()
}),
// Cap the growth so a pasted wall of text cannot swallow the message list.
maxLines = 6,
visualTransformation = remember(palette) {
CombinedVisualTransformation(
listOf(
SlashCommandVisualTransformation(
commandColor = colorScheme.primary,
commandBackground = colorScheme.primary.copy(alpha = 0.14f),
),
MentionVisualTransformation(
mentionColor = palette.accentOrange,
mentionBackground = palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA),
),
)
)
},
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
isFocused.value = focusState.isFocused
}
)
// Placeholder fades rather than blinking, which matters because it reappears
// every time a message is sent.
val placeholderAlpha by animateFloatAsState(
targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "placeholderAlpha"
)
if (placeholderAlpha > 0f) {
Text(
text = stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = BitchatFontFamily
),
color = palette.textTertiary,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.alpha(placeholderAlpha)
)
}
// Recording visualiser, layered over the (empty) field.
val waveformAlpha by animateFloatAsState(
targetValue = if (isRecording) 1f else 0f,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "waveformAlpha"
)
if (isRecording) {
Row(
modifier = Modifier
.fillMaxWidth()
.alpha(waveformAlpha),
verticalAlignment = Alignment.CenterVertically
) {
RealtimeScrollingWaveform(
modifier = Modifier.weight(1f).height(22.dp),
amplitudeNorm = normalizeAmplitudeSample(amplitude)
)
Spacer(Modifier.width(12.dp))
val secs = (elapsedMs / 1000).toInt()
val maxSecs = 10 // 10 second max recording time
Text(
text = String.format(
"%02d:%02d / %02d:%02d",
secs / 60, secs % 60, maxSecs / 60, maxSecs % 60
),
fontFamily = BitchatFontFamily,
color = colorScheme.error,
fontSize = (BASE_FONT_SIZE - 4).sp
)
}
}
}
// MARK: - Action cluster, inside the pill.
//
// Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and
// scales between the two, and SizeTransform animates the width change, so typing the
// first character morphs camera+mic into send instead of snapping.
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
val latestChannel = rememberUpdatedState(currentChannel)
val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote)
AnimatedContent(
targetState = hasText,
transitionSpec = {
(
fadeIn(tween(BitchatMotion.STANDARD_MS)) +
scaleIn(
initialScale = 0.7f,
animationSpec = tween(
BitchatMotion.STANDARD_MS,
easing = FastOutSlowInEasing
)
)
).togetherWith(
fadeOut(tween(BitchatMotion.QUICK_MS)) +
scaleOut(
targetScale = 0.7f,
animationSpec = tween(
BitchatMotion.QUICK_MS,
easing = FastOutSlowInEasing
)
)
) using SizeTransform(clip = false) { _, _ ->
tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
}
},
modifier = Modifier.padding(end = 6.dp, bottom = 6.dp),
label = "composerActions"
) { showSend ->
if (showSend) {
SendButton(
isAccented = latestSelectedPeer.value != null || latestChannel.value != null,
onSend = onSend
)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
if (showMediaButtons) {
// The camera steps aside while recording so the microphone is the
// only thing that can be released.
AnimatedVisibility(
visible = !isRecording,
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
expandHorizontally(
tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
),
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
shrinkHorizontally(
tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
)
) {
ImagePickerButton(
onImageReady = { outPath ->
onSendImageNote(
latestSelectedPeer.value,
latestChannel.value,
outPath
)
}
)
}
VoiceRecordButton(
isRecording = isRecording,
onStart = {
isRecording = true
elapsedMs = 0L
// Keep existing focus to avoid IME collapse, but do not
// force-show the keyboard.
if (isFocused.value) {
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
},
onAmplitude = { amp, ms ->
amplitude = amp
elapsedMs = ms
},
onFinish = { path ->
isRecording = false
// Extract and cache the waveform from the actual audio file
// so it matches the receiver's rendering.
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
if (arr != null) {
try {
com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr)
} catch (_: Exception) {}
}
}
latestOnSendVoiceNote.value(
latestSelectedPeer.value,
latestChannel.value,
path
)
},
// Any capture that ends without a file must clear the recording
// state here too, otherwise the pill stays red with a live
// waveform over an empty field.
onCancel = {
isRecording = false
amplitude = 0
elapsedMs = 0L
}
)
} else {
// No media in this context, so keep an inert send button rather than
// leaving a hole where the action cluster should be.
SendButton(isAccented = false, onSend = {}, enabled = false)
}
}
}
}
}
}
// Auto-stop handled inside VoiceRecordButton
}
/**
* Send affordance. Only rendered when there is something to send, so its mere presence is the
* signal; it does not need to shout in the terminal's full-brightness green as well.
*/
@Composable
private fun SendButton(
isAccented: Boolean,
onSend: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
ComposerActionSurface(
isActive = enabled,
isPressed = isPressed,
// Private chats and channels keep their orange identity, disc and glyph together.
activeColor = if (isAccented) palette.accentOrange else colorScheme.primary,
modifier = modifier.clickable(
interactionSource = interactionSource,
indication = null,
enabled = enabled
) { onSend() }
) { tint ->
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(id = R.string.send_message),
modifier = Modifier.size(ComposerIconSize),
tint = tint
)
}
}
@Composable
fun CommandSuggestionsBox(
suggestions: List<CommandSuggestion>,
onSuggestionClick: (CommandSuggestion) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.background(colorScheme.surface)
.border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp))
.padding(vertical = 6.dp)
) {
suggestions.forEach { suggestion: CommandSuggestion ->
CommandSuggestionItem(
suggestion = suggestion,
onClick = { onSuggestionClick(suggestion) }
)
}
}
}
@Composable
fun CommandSuggestionItem(
suggestion: CommandSuggestion,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
// Roomier rows: at 3.dp vertical these were below a comfortable tap height.
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Show all aliases together
val allCommands = if (suggestion.aliases.isNotEmpty()) {
listOf(suggestion.command) + suggestion.aliases
} else {
listOf(suggestion.command)
}
Text(
text = allCommands.joinToString(", "),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium
),
color = colorScheme.primary,
fontSize = (BASE_FONT_SIZE - 2).sp
)
// Show syntax if any
suggestion.syntax?.let { syntax ->
Text(
text = syntax,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily
),
color = colorScheme.onSurfaceVariant,
fontSize = (BASE_FONT_SIZE - 4).sp
)
}
// Show description
Text(
text = suggestion.description,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily
),
color = palette.textTertiary,
fontSize = (BASE_FONT_SIZE - 4).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MentionSuggestionsBox(
suggestions: List<String>,
mentionPeerIdentities: Map<String, PeerIdentity>,
onSuggestionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
LazyColumn(
modifier = modifier
.heightIn(max = MentionSuggestionsMaxHeight)
.animateContentSize(
animationSpec = tween(
BitchatMotion.STANDARD_MS,
easing = FastOutSlowInEasing
)
)
.clip(MentionSuggestionsShape)
.background(colorScheme.surface)
.border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape),
contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding)
) {
items(
items = suggestions,
key = { suggestion -> suggestion.lowercase() }
) { suggestion ->
val identity = resolveMentionPeerIdentity(suggestion, mentionPeerIdentities)
?: PeerIdentity.nickname(suggestion)
MentionSuggestionItem(
suggestion = suggestion,
identity = identity,
onClick = { onSuggestionClick(suggestion) },
modifier = Modifier.animateItem(
fadeInSpec = tween(
BitchatMotion.STANDARD_MS,
easing = FastOutSlowInEasing
),
placementSpec = tween(
BitchatMotion.STANDARD_MS,
easing = FastOutSlowInEasing
),
fadeOutSpec = tween(BitchatMotion.QUICK_MS)
)
)
}
}
}
@Composable
fun MentionSuggestionItem(
suggestion: String,
identity: PeerIdentity,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val userColor = colorForPeer(identity, palette)
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressedBackground by animateColorAsState(
targetValue = if (isPressed) {
userColor.copy(alpha = 0.10f)
} else {
Color.Transparent
},
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
label = "mentionSuggestionPressedBackground"
)
val pressedScale by animateFloatAsState(
targetValue = if (isPressed) 0.985f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "mentionSuggestionPressedScale"
)
Row(
modifier = modifier
.fillMaxWidth()
.height(MentionSuggestionRowHeight)
.scale(pressedScale)
.background(pressedBackground)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.mention_suggestion_at, suggestion),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold
),
color = userColor,
fontSize = (BASE_FONT_SIZE - 2).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.mention),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = BitchatFontFamily
),
color = palette.textTertiary,
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)