mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
989 lines
40 KiB
Kotlin
989 lines
40 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
import android.view.HapticFeedbackConstants
|
|
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.animateDpAsState
|
|
import androidx.compose.animation.core.animateFloatAsState
|
|
import androidx.compose.animation.core.animateOffsetAsState
|
|
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.geometry.Offset
|
|
import androidx.compose.ui.geometry.Rect
|
|
import androidx.compose.ui.layout.onGloballyPositioned
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.platform.LocalView
|
|
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.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.IntOffset
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.toSize
|
|
import kotlin.math.roundToInt
|
|
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 com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
|
import com.bitchat.android.ui.theme.BitchatPalette
|
|
import com.bitchat.android.ui.theme.BitchatMotion
|
|
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
|
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 builder = AnnotatedString.Builder(text)
|
|
slashCommandRegex.findAll(text.text).forEach { match ->
|
|
builder.addStyle(
|
|
style = SpanStyle(
|
|
color = commandColor,
|
|
fontFamily = BitchatFontFamily,
|
|
fontWeight = FontWeight.Medium,
|
|
background = commandBackground
|
|
),
|
|
start = match.range.first,
|
|
end = match.range.last + 1,
|
|
)
|
|
}
|
|
|
|
return TransformedText(
|
|
text = builder.toAnnotatedString(),
|
|
offsetMapping = OffsetMapping.Identity
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* VisualTransformation that styles mentions with background and color
|
|
* while preserving cursor positioning and click handling
|
|
*/
|
|
class MentionVisualTransformation(
|
|
private val mentionPeerIdentities: Map<String, PeerIdentity>,
|
|
private val palette: BitchatPalette,
|
|
) : VisualTransformation {
|
|
override fun filter(text: AnnotatedString): TransformedText {
|
|
val builder = AnnotatedString.Builder(text)
|
|
|
|
MENTION_TOKEN_REGEX.findAll(text.text).forEach { match ->
|
|
val start = match.range.first
|
|
val end = match.range.last + 1
|
|
val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 }
|
|
val suffixStart = suffixOffset?.let(start::plus) ?: end
|
|
val mentionColor = colorForMention(
|
|
mention = match.value,
|
|
mentionPeerIdentities = mentionPeerIdentities,
|
|
palette = palette,
|
|
)
|
|
|
|
// Keep the whole token on one continuous color-derived chip.
|
|
builder.addStyle(
|
|
style = SpanStyle(
|
|
background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA),
|
|
),
|
|
start = start,
|
|
end = end,
|
|
)
|
|
builder.addStyle(
|
|
style = SpanStyle(
|
|
color = mentionColor,
|
|
fontFamily = BitchatFontFamily,
|
|
fontWeight = FontWeight.SemiBold,
|
|
),
|
|
start = start,
|
|
end = suffixStart,
|
|
)
|
|
if (suffixStart < end) {
|
|
builder.addStyle(
|
|
style = SpanStyle(
|
|
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
|
|
fontFamily = BitchatFontFamily,
|
|
fontWeight = FontWeight.SemiBold,
|
|
),
|
|
start = suffixStart,
|
|
end = end,
|
|
)
|
|
}
|
|
}
|
|
|
|
return TransformedText(
|
|
text = builder.toAnnotatedString(),
|
|
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,
|
|
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
|
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) }
|
|
|
|
// Slide-to-cancel: while recording, the mic button streams the finger position (root
|
|
// coords) up here; the cancel disc beside it reports its bounds. Approaching the disc
|
|
// makes it lean toward the finger and blush red; only entering it activates cancel.
|
|
var cancelBounds by remember { mutableStateOf<Rect?>(null) }
|
|
var cancelFinger by remember { mutableStateOf<Offset?>(null) }
|
|
val density = LocalDensity.current
|
|
val cancelSlackPx = with(density) { 8.dp.toPx() }
|
|
val cancelHover = cancelFinger != null &&
|
|
cancelBounds?.inflate(cancelSlackPx)?.contains(cancelFinger!!) == true
|
|
val cancelCenter = cancelBounds?.center
|
|
val cancelProximity: Float
|
|
val cancelPull: Offset
|
|
val trackedFinger = cancelFinger
|
|
if (trackedFinger != null && cancelCenter != null) {
|
|
val toFinger = trackedFinger - cancelCenter
|
|
val dist = toFinger.getDistance()
|
|
val outer = with(density) { 36.dp.toPx() }
|
|
val inner = with(density) { 18.dp.toPx() }
|
|
cancelProximity = ((outer - dist) / (outer - inner)).coerceIn(0f, 1f)
|
|
cancelPull = if (dist > 1f) {
|
|
toFinger * (cancelProximity * with(density) { 12.dp.toPx() } / dist)
|
|
} else Offset.Zero
|
|
} else {
|
|
cancelProximity = 0f
|
|
cancelPull = Offset.Zero
|
|
}
|
|
// A firm, physical click each time the finger enters or leaves the cancel target.
|
|
val view = LocalView.current
|
|
var cancelHoverHapticState by remember { mutableStateOf(false) }
|
|
LaunchedEffect(cancelHover, isRecording) {
|
|
if (!isRecording) {
|
|
cancelHoverHapticState = false
|
|
} else if (cancelHover != cancelHoverHapticState) {
|
|
view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
|
|
cancelHoverHapticState = cancelHover
|
|
}
|
|
}
|
|
|
|
// Recording is the one state worth shouting about, so it overrides focus. While recording
|
|
// the outline also firms up slightly in the same fast sweep — present, but muted.
|
|
val borderColor by animateColorAsState(
|
|
targetValue = when {
|
|
isRecording -> colorScheme.error.copy(alpha = 0.65f)
|
|
isFocused.value -> palette.inputOutlineFocused
|
|
else -> palette.inputOutline
|
|
},
|
|
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
|
label = "composerBorder"
|
|
)
|
|
val borderWidth by animateDpAsState(
|
|
targetValue = if (isRecording) 1.5.dp else 1.dp,
|
|
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
|
label = "composerBorderWidth"
|
|
)
|
|
// A barely-there lift on focus. While recording the pill turns into a neutral grey slab
|
|
// (NOT the brand-tinted elevation color) so it protrudes from the flat black chat.
|
|
val containerColor by animateColorAsState(
|
|
targetValue = when {
|
|
isRecording -> colorScheme.surfaceVariant.copy(alpha = 0.97f)
|
|
else -> (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(borderWidth, 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,
|
|
colorScheme.primary,
|
|
mentionPeerIdentities,
|
|
) {
|
|
CombinedVisualTransformation(
|
|
listOf(
|
|
SlashCommandVisualTransformation(
|
|
commandColor = colorScheme.primary,
|
|
commandBackground = colorScheme.primary.copy(alpha = 0.14f),
|
|
),
|
|
MentionVisualTransformation(
|
|
mentionPeerIdentities = mentionPeerIdentities,
|
|
palette = palette,
|
|
),
|
|
)
|
|
)
|
|
},
|
|
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
|
|
) {
|
|
// Timestamp on the left, clear of the thumb resting on the record
|
|
// button; the waveform keeps the remaining width and its history
|
|
// scrolls off the left edge while live data streams in from the right.
|
|
val secs = (elapsedMs / 1000).toInt()
|
|
Text(
|
|
text = String.format("%02d:%02d", secs / 60, secs % 60),
|
|
fontFamily = BitchatFontFamily,
|
|
color = colorScheme.error,
|
|
fontSize = (BASE_FONT_SIZE - 4).sp
|
|
)
|
|
Spacer(Modifier.width(12.dp))
|
|
RealtimeScrollingWaveform(
|
|
modifier = Modifier.weight(1f).height(22.dp),
|
|
amplitudeNorm = normalizeAmplitudeSample(amplitude)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
// The slide-to-cancel target sits well clear of the record
|
|
// button (camera's slot plus a gap), rests as a cancel disc,
|
|
// leans toward an approaching finger and snaps red on hover.
|
|
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)
|
|
)
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
RecordingCancelButton(
|
|
hover = cancelHover,
|
|
proximity = cancelProximity,
|
|
pull = cancelPull,
|
|
onBounds = { cancelBounds = it }
|
|
)
|
|
Spacer(Modifier.width(24.dp))
|
|
}
|
|
}
|
|
|
|
VoiceRecordButton(
|
|
isRecording = isRecording,
|
|
shouldCancel = { cancelHover },
|
|
onTrackFinger = { cancelFinger = it },
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Slide-to-cancel target shown beside the record button while capturing. It always shows the
|
|
* cancel glyph so the destination is unambiguous; as the finger approaches it leans toward
|
|
* it (magnetic pull) and blushes red, and on contact it blooms. Release there cancels;
|
|
* sliding back out returns to send mode. All motion is spring-driven so it stays fluid.
|
|
*/
|
|
@Composable
|
|
private fun RecordingCancelButton(
|
|
hover: Boolean,
|
|
proximity: Float,
|
|
pull: Offset,
|
|
onBounds: (Rect) -> Unit,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val palette = LocalBitchatPalette.current
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
val pullAnim by animateOffsetAsState(
|
|
targetValue = pull,
|
|
animationSpec = spring(
|
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
stiffness = Spring.StiffnessMedium
|
|
),
|
|
label = "cancelPull"
|
|
)
|
|
val scale by animateFloatAsState(
|
|
targetValue = if (hover) 1.28f else 1f + 0.1f * proximity,
|
|
animationSpec = spring(
|
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
|
stiffness = Spring.StiffnessMedium
|
|
),
|
|
label = "cancelScale"
|
|
)
|
|
val container = androidx.compose.ui.graphics.lerp(
|
|
palette.inputButton,
|
|
colorScheme.error,
|
|
if (hover) 1f else proximity * 0.85f
|
|
)
|
|
val tint = androidx.compose.ui.graphics.lerp(
|
|
colorScheme.onSurfaceVariant,
|
|
colorScheme.onError,
|
|
if (hover) 1f else proximity * 0.6f
|
|
)
|
|
|
|
Box(
|
|
modifier = modifier
|
|
.onGloballyPositioned { coords ->
|
|
onBounds(Rect(coords.localToRoot(Offset.Zero), coords.size.toSize()))
|
|
}
|
|
.size(ComposerButtonSize),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.size(ComposerButtonDisc)
|
|
.scale(scale)
|
|
.offset { IntOffset(pullAnim.x.roundToInt(), pullAnim.y.roundToInt()) }
|
|
.background(container, CircleShape),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.Close,
|
|
contentDescription = "Cancel recording",
|
|
tint = tint,
|
|
modifier = Modifier.size(ComposerIconSize)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
val palette = LocalBitchatPalette.current
|
|
|
|
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 ->
|
|
MentionSuggestionItem(
|
|
suggestion = suggestion,
|
|
userColor = colorForMention(
|
|
mention = suggestion,
|
|
mentionPeerIdentities = mentionPeerIdentities,
|
|
palette = palette,
|
|
),
|
|
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,
|
|
userColor: Color,
|
|
onClick: () -> Unit,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val palette = LocalBitchatPalette.current
|
|
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)
|