mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Merge pull request #846 from permissionlesstech/bitchat-ui-bubbles
feat(chat): add bubbles chat UI mode with peer-color bubbles
This commit is contained in:
commit
2e943b2ebc
@ -48,6 +48,9 @@ class BitchatApplication : Application() {
|
||||
// Initialize theme preference
|
||||
ThemePreferenceManager.init(this)
|
||||
|
||||
// Initialize chat UI mode (matrix transcript vs bubbles)
|
||||
com.bitchat.android.ui.theme.ChatUiModeManager.init(this)
|
||||
|
||||
// Initialize debug preference manager (persists debug toggles)
|
||||
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
||||
|
||||
|
||||
@ -52,6 +52,7 @@ fun AnnotatedClickableText(
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
) {
|
||||
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick)
|
||||
@ -91,6 +92,9 @@ fun AnnotatedClickableText(
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
style = style,
|
||||
onTextLayout = { layoutResult = it },
|
||||
onTextLayout = { result ->
|
||||
layoutResult = result
|
||||
onTextLayout?.invoke(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -383,6 +383,7 @@ fun AboutSheet(
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_theme))
|
||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||
val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState()
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -390,30 +391,52 @@ fun AboutSheet(
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_system),
|
||||
selected = themePref.isSystem,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_light),
|
||||
selected = themePref.isLight,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_dark),
|
||||
selected = themePref.isDark,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_system),
|
||||
selected = themePref.isSystem,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_light),
|
||||
selected = themePref.isLight,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_dark),
|
||||
selected = themePref.isDark,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.chat_ui_bubbles),
|
||||
selected = chatUiMode.isBubbles,
|
||||
onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.chat_ui_matrix),
|
||||
selected = chatUiMode.isMatrix,
|
||||
onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,11 +53,14 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
}
|
||||
}
|
||||
|
||||
/** Thin space (U+2009) separating a display name from its `#abcd` disambiguation suffix. */
|
||||
internal const val SUFFIX_THIN_SPACE = " "
|
||||
|
||||
/**
|
||||
* Build the sender label shown above the first message of a group.
|
||||
*
|
||||
* Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click`
|
||||
* annotation for everyone except yourself.
|
||||
* Renders `@name` plus a dimmed `#abcd` suffix, separated by a thin space. The name carries
|
||||
* a `nickname_click` annotation for everyone except yourself.
|
||||
*/
|
||||
fun formatTextMessageSender(
|
||||
message: BitchatMessage,
|
||||
@ -97,6 +100,7 @@ fun formatTextMessageSender(
|
||||
builder.pop()
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.append(SUFFIX_THIN_SPACE)
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor.copy(alpha = SUFFIX_ALPHA),
|
||||
@ -304,6 +308,7 @@ fun formatMessageHeaderAnnotatedString(
|
||||
builder.pop()
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.append(SUFFIX_THIN_SPACE)
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA),
|
||||
|
||||
@ -8,7 +8,7 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
@ -16,9 +16,6 @@ import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@ -27,15 +24,19 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@ -49,6 +50,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -61,16 +63,21 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
@ -83,10 +90,12 @@ import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.ui.media.FileMessageItem
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.ChatUiModeManager
|
||||
import com.bitchat.android.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.theme.MessageBodyTextStyle
|
||||
import com.bitchat.android.ui.theme.MessageSenderTextStyle
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@ -223,6 +232,10 @@ fun MessagesList(
|
||||
mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages)
|
||||
}
|
||||
|
||||
// Collected once here so individual rows never subscribe to the preference flow; a mode
|
||||
// switch simply recomposes the list against the new layout.
|
||||
val bubbles by ChatUiModeManager.modeFlow.collectAsState()
|
||||
|
||||
// A fresh scroll position per conversation. Sharing one state meant a switch inherited the
|
||||
// previous channel's offset and then had to correct itself, which is what the jump was.
|
||||
//
|
||||
@ -352,6 +365,7 @@ fun MessagesList(
|
||||
meshService = meshService,
|
||||
mentionPeerIdentities = resolvedMentionPeerIdentities,
|
||||
showSender = !isGrouped,
|
||||
bubbles = bubbles.isBubbles,
|
||||
topSpacing = MessageGrouping.topSpacingFor(
|
||||
isGrouped = isGrouped,
|
||||
isFirstInList = originalIndex == 0
|
||||
@ -385,6 +399,7 @@ fun MessageItem(
|
||||
messages: List<BitchatMessage> = emptyList(),
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false,
|
||||
topSpacing: Dp = 0.dp,
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||
@ -407,8 +422,9 @@ fun MessageItem(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Provide a small end padding for own private messages so overlay doesn't cover text
|
||||
val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
|
||||
// Provide a small end padding for own private messages so overlay doesn't cover text.
|
||||
// Bubble mode draws the status beneath the bubble instead, so no inset is needed.
|
||||
val endPad = if (!bubbles && message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
|
||||
// Create a custom layout that combines selectable text with clickable nickname areas
|
||||
MessageTextWithClickableNicknames(
|
||||
message = message,
|
||||
@ -419,6 +435,7 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -429,8 +446,9 @@ fun MessageItem(
|
||||
)
|
||||
}
|
||||
|
||||
// Delivery status for private messages (overlay, non-displacing)
|
||||
if (message.isPrivate && message.sender == currentUserNickname) {
|
||||
// Delivery status for private messages (overlay, non-displacing). Bubble mode aligns
|
||||
// own messages to the end edge where this overlay lives, so it renders below instead.
|
||||
if (!bubbles && message.isPrivate && message.sender == currentUserNickname) {
|
||||
message.deliveryStatus?.let { status ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@ -442,7 +460,24 @@ fun MessageItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Bubble mode: text messages carry the marker inline, trailing the timestamp. Media
|
||||
// rows have no inline text, so their marker stays beneath the end-aligned card.
|
||||
if (bubbles && message.type != BitchatMessageType.Message &&
|
||||
message.isPrivate && message.sender == currentUserNickname
|
||||
) {
|
||||
message.deliveryStatus?.let { status ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp, end = 4.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
DeliveryStatusIcon(status = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Link previews removed; links are now highlighted inline and clickable within the message text
|
||||
}
|
||||
}
|
||||
@ -458,6 +493,7 @@ fun MessageItem(
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
showSender: Boolean,
|
||||
bubbles: Boolean = false,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
@ -476,6 +512,7 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -494,6 +531,7 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
@ -514,7 +552,15 @@ fun MessageItem(
|
||||
}
|
||||
else -> null to null
|
||||
}
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
// Bubble mode aligns self-authored file rows to the end side, mirroring text bubbles.
|
||||
horizontalAlignment = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
|
||||
Alignment.End
|
||||
} else {
|
||||
Alignment.Start
|
||||
},
|
||||
) {
|
||||
// Header: nickname + timestamp line above the file, identical styling to text messages
|
||||
val headerText = formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
@ -560,7 +606,14 @@ fun MessageItem(
|
||||
null
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)) {
|
||||
Arrangement.End
|
||||
} else {
|
||||
Arrangement.Start
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
if (packet != null) {
|
||||
if (overrideProgress != null) {
|
||||
@ -624,6 +677,7 @@ fun MessageItem(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier
|
||||
@ -668,6 +722,7 @@ fun MessageItem(
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
showSender = showSender,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
@ -687,6 +742,7 @@ internal fun TextMessageLayout(
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false,
|
||||
bodyContent: String = message.content,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
@ -702,8 +758,17 @@ internal fun TextMessageLayout(
|
||||
palette = palette,
|
||||
)
|
||||
}
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
val handleLongPress: () -> Unit = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
}
|
||||
|
||||
// The timestamp trails the body rather than occupying its own column, so a short message
|
||||
// no longer reserves a full-width row for eight grey characters.
|
||||
// no longer reserves a full-width row for eight grey characters. Self bubbles leave the
|
||||
// timestamp to their meta cluster (see BubbleTextMessageLayout).
|
||||
val bodyText = remember(
|
||||
displayMessage,
|
||||
currentUserNickname,
|
||||
@ -711,7 +776,9 @@ internal fun TextMessageLayout(
|
||||
colorScheme.onSurface,
|
||||
colorScheme.secondary,
|
||||
mentionPeerIdentities,
|
||||
timeFormatter
|
||||
timeFormatter,
|
||||
bubbles,
|
||||
isSelf
|
||||
) {
|
||||
formatTextMessageBody(
|
||||
message = displayMessage,
|
||||
@ -721,14 +788,23 @@ internal fun TextMessageLayout(
|
||||
linkColor = colorScheme.secondary,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
timeFormatter = timeFormatter,
|
||||
includeTimestamp = !bubbles || !isSelf,
|
||||
)
|
||||
}
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
val handleLongPress: () -> Unit = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
|
||||
if (bubbles) {
|
||||
BubbleTextMessageLayout(
|
||||
message = message,
|
||||
senderText = senderText,
|
||||
bodyText = bodyText,
|
||||
isSelf = isSelf,
|
||||
showSender = showSender,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onLongPress = handleLongPress,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
@ -788,6 +864,199 @@ internal fun TextMessageLayout(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classic messenger rendering of a text message: a rounded bubble that hugs its content, own
|
||||
* messages on the right and everyone else on the left, with the corner on the speaker's side
|
||||
* tightened into a subtle tail.
|
||||
*
|
||||
* The bubble is washed with the author's stable peer colour — the same identity-derived colour
|
||||
* the `@name` label and mention chips already use — so the speaker stays identifiable at a
|
||||
* glance without touching any surface, background, or theme colour. Body text keeps the
|
||||
* standard `onSurface` tone; only the bubble shell carries the identity.
|
||||
*/
|
||||
@Composable
|
||||
private fun BubbleTextMessageLayout(
|
||||
message: BitchatMessage,
|
||||
senderText: AnnotatedString,
|
||||
bodyText: AnnotatedString,
|
||||
isSelf: Boolean,
|
||||
showSender: Boolean,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onLongPress: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val authorColor = remember(message, isSelf, palette) {
|
||||
if (isSelf) palette.accentOrange else colorForPeer(peerIdentityForMessage(message), palette)
|
||||
}
|
||||
|
||||
val corner = ChatVisualTokens.BubbleCornerRadius
|
||||
val tail = ChatVisualTokens.BubbleTailRadius
|
||||
val bubbleShape = if (isSelf) {
|
||||
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
// Cap the bubble at a fraction of the row so long messages wrap instead of touching the
|
||||
// opposite edge, while short ones hug their content.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val maxBubbleWidth = maxWidth * ChatVisualTokens.BubbleMaxWidthFraction
|
||||
val density = LocalDensity.current
|
||||
val textCapPx = with(density) {
|
||||
(maxBubbleWidth - ChatVisualTokens.BubblePaddingHorizontal * 2 - 2.dp).toPx()
|
||||
}
|
||||
var bodyLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
var clusterSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
// The meta cluster (timestamp, then delivery checks for own private messages) rides
|
||||
// flush-right on the body's last line when that line has room for it, and drops
|
||||
// below the text only when it does not. Placement is computed from the laid-out
|
||||
// text, so wrapping is never influenced by the cluster: no early wraps, no slack
|
||||
// carved out of the first lines, no minimum bubble width.
|
||||
val metaGapPx = with(density) { 8.dp.toPx() }
|
||||
val metaPlan = remember(bodyLayout, clusterSize, textCapPx) {
|
||||
val layout = bodyLayout ?: return@remember null
|
||||
if (layout.lineCount == 0 || clusterSize.width <= 0) return@remember null
|
||||
val lastLineRight = layout.getLineRight(layout.lineCount - 1)
|
||||
if (lastLineRight + metaGapPx + clusterSize.width <= textCapPx) {
|
||||
BubbleMetaPlan(
|
||||
widthPx = maxOf(layout.size.width.toFloat(), lastLineRight + metaGapPx + clusterSize.width),
|
||||
reserveOwnLine = false,
|
||||
)
|
||||
} else {
|
||||
BubbleMetaPlan(
|
||||
widthPx = maxOf(layout.size.width.toFloat(), clusterSize.width.toFloat()),
|
||||
reserveOwnLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(if (isSelf) Alignment.CenterEnd else Alignment.CenterStart)
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBorderAlpha),
|
||||
shape = bubbleShape
|
||||
)
|
||||
.background(
|
||||
color = authorColor.copy(alpha = ChatVisualTokens.BubbleBackgroundAlpha),
|
||||
shape = bubbleShape
|
||||
)
|
||||
.padding(
|
||||
horizontal = ChatVisualTokens.BubblePaddingHorizontal,
|
||||
vertical = ChatVisualTokens.BubblePaddingVertical,
|
||||
)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
// The sender's name heads the first bubble of their run, like classic group
|
||||
// messengers, instead of floating above it. Own bubbles never show a name —
|
||||
// the end side is attribution enough. Continuation bubbles skip it too.
|
||||
if (showSender && !isSelf) {
|
||||
AnnotatedClickableText(
|
||||
text = senderText,
|
||||
annotationTags = listOf("nickname_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
if (tag == "nickname_click" && !isSelf && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(item)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
onLongPress = onLongPress,
|
||||
fontFamily = BitchatFontFamily,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MessageSenderTextStyle,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = if (isSelf && metaPlan != null) {
|
||||
Modifier.width(with(density) { metaPlan!!.widthPx.toDp() })
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
) {
|
||||
AnnotatedClickableText(
|
||||
text = bodyText,
|
||||
annotationTags = listOf("geohash_click", "url_click"),
|
||||
onAnnotationClick = { tag, item ->
|
||||
when (tag) {
|
||||
"geohash_click" -> {
|
||||
navigateToGeohash(context, item)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
true
|
||||
}
|
||||
|
||||
"url_click" -> {
|
||||
openMessageUrl(context, item)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
onLongPress = onLongPress,
|
||||
modifier = Modifier.padding(
|
||||
bottom = if (metaPlan?.reserveOwnLine == true) {
|
||||
with(density) { clusterSize.height.toDp() }
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
),
|
||||
fontFamily = BitchatFontFamily,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
onTextLayout = { bodyLayout = it },
|
||||
)
|
||||
|
||||
if (isSelf) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.onSizeChanged { clusterSize = it }
|
||||
.graphicsLayer { alpha = if (metaPlan != null) 1f else 0f },
|
||||
) {
|
||||
Text(
|
||||
text = formatTextMessageMetadata(message, timeFormatter),
|
||||
fontFamily = BitchatFontFamily,
|
||||
)
|
||||
if (message.isPrivate) {
|
||||
message.deliveryStatus?.let { status ->
|
||||
Spacer(Modifier.width(4.dp))
|
||||
DeliveryStatusIcon(status = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class BubbleMetaPlan(
|
||||
val widthPx: Float,
|
||||
val reserveOwnLine: Boolean,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun CashuMessageContent(
|
||||
@ -797,6 +1066,7 @@ private fun CashuMessageContent(
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
bubbles: Boolean = false,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
@ -816,6 +1086,7 @@ private fun CashuMessageContent(
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
bubbles = bubbles,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
bodyContent = remainingText,
|
||||
@ -931,43 +1202,77 @@ private fun redeemCashu(context: Context, token: String, preferWallet: Boolean)
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(web))) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-check target colours for the delivery marker.
|
||||
*
|
||||
* Both checks always render — grey (disabled) until an acknowledgement turns them on — so a
|
||||
* status change recolours in place and never reflows text around it. Read receipts use the
|
||||
* app's primary green rather than a separate accent. [status] == null yields the all-grey
|
||||
* baseline used while a message is still being sent.
|
||||
*/
|
||||
private fun deliveryCheckColors(status: DeliveryStatus?, colorScheme: ColorScheme): Pair<Color, Color> {
|
||||
val grey = colorScheme.onSurface.copy(alpha = 0.35f)
|
||||
val green = colorScheme.primary
|
||||
return when (status) {
|
||||
is DeliveryStatus.Read -> green to green
|
||||
is DeliveryStatus.Delivered -> green to grey
|
||||
is DeliveryStatus.PartiallyDelivered -> green to grey
|
||||
is DeliveryStatus.Failed -> colorScheme.error to colorScheme.error
|
||||
else -> grey to grey
|
||||
}
|
||||
}
|
||||
|
||||
/** Acknowledgement progress ordering, used to fire the pop only when the state advances. */
|
||||
private fun deliveryCheckRank(status: DeliveryStatus): Int = when (status) {
|
||||
is DeliveryStatus.Read -> 3
|
||||
is DeliveryStatus.Delivered -> 2
|
||||
is DeliveryStatus.PartiallyDelivered -> 2
|
||||
is DeliveryStatus.Failed -> 1
|
||||
else -> 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val (firstTarget, secondTarget) = deliveryCheckColors(status, colorScheme)
|
||||
val first by animateColorAsState(
|
||||
targetValue = firstTarget,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS),
|
||||
label = "firstCheckColor",
|
||||
)
|
||||
val second by animateColorAsState(
|
||||
targetValue = secondTarget,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS),
|
||||
label = "secondCheckColor",
|
||||
)
|
||||
|
||||
// Status advances on its own as acks come back, so a hard glyph swap reads as a flicker.
|
||||
// Keyed on the status *type* rather than the instance, because Delivered/Read carry a
|
||||
// timestamp that would otherwise retrigger the transition on every identical update.
|
||||
AnimatedContent(
|
||||
targetState = status::class,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith
|
||||
fadeOut(tween(BitchatMotion.QUICK_MS))
|
||||
},
|
||||
label = "deliveryStatus"
|
||||
) { statusClass ->
|
||||
val (text, color, weight) = when (statusClass) {
|
||||
DeliveryStatus.Sending::class ->
|
||||
Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
// Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity).
|
||||
DeliveryStatus.Sent::class ->
|
||||
Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
DeliveryStatus.Delivered::class ->
|
||||
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal)
|
||||
DeliveryStatus.Read::class ->
|
||||
Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold)
|
||||
DeliveryStatus.Failed::class ->
|
||||
Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal)
|
||||
// A single subdued check, without the numeric label.
|
||||
else ->
|
||||
Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
|
||||
// Snappy micro pop when the state advances to (more) acknowledged. Keyed on the rank, not
|
||||
// the instance, because Delivered/Read carry timestamps that would retrigger it otherwise.
|
||||
val scale = remember { Animatable(1f) }
|
||||
LaunchedEffect(deliveryCheckRank(status)) {
|
||||
if (deliveryCheckRank(status) >= 2) {
|
||||
scale.snapTo(1.3f)
|
||||
scale.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = 900f))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(text),
|
||||
fontSize = 10.sp,
|
||||
color = color,
|
||||
fontWeight = weight
|
||||
)
|
||||
}
|
||||
|
||||
val text = remember(first, second) {
|
||||
androidx.compose.ui.text.buildAnnotatedString {
|
||||
pushStyle(androidx.compose.ui.text.SpanStyle(color = first))
|
||||
append("✓")
|
||||
pop()
|
||||
pushStyle(androidx.compose.ui.text.SpanStyle(color = second))
|
||||
append("✓")
|
||||
pop()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
scaleX = scale.value
|
||||
scaleY = scale.value
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.isFromSelf
|
||||
import java.text.SimpleDateFormat
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@ -38,7 +39,8 @@ fun AudioMessageItem(
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val context = LocalContext.current
|
||||
@ -46,6 +48,8 @@ fun AudioMessageItem(
|
||||
.getInstance(context).liveMessageIDs.collectAsState()
|
||||
val isLive = message.id in liveMessageIDs
|
||||
val path = message.content.trim()
|
||||
// Bubble mode aligns self-authored voice notes to the end side, mirroring text bubbles.
|
||||
val isSelfInBubbles = bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
// Derive sending progress if applicable
|
||||
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
|
||||
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
|
||||
@ -55,7 +59,10 @@ fun AudioMessageItem(
|
||||
}
|
||||
else -> null to null
|
||||
}
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (isSelfInBubbles) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
// Header: nickname + timestamp line above the audio note, identical styling to text messages
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
@ -96,7 +103,10 @@ fun AudioMessageItem(
|
||||
VoiceNotePlayer(
|
||||
path = path,
|
||||
progressOverride = overrideProgress,
|
||||
progressColor = overrideColor
|
||||
progressColor = overrideColor,
|
||||
// Self voice notes hug the end side like other self content instead of
|
||||
// spanning the full row.
|
||||
modifier = if (isSelfInBubbles) Modifier.widthIn(max = 300.dp) else Modifier
|
||||
)
|
||||
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
|
||||
if (showCancel) {
|
||||
|
||||
@ -28,6 +28,7 @@ import com.bitchat.android.model.BitchatMessageType
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.ui.isFromSelf
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Composable
|
||||
@ -43,11 +44,23 @@ fun ImageMessageItem(
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
showSender: Boolean = true
|
||||
showSender: Boolean = true,
|
||||
bubbles: Boolean = false
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val path = message.content.trim()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Bubble mode aligns self-authored media to the end side, mirroring text bubbles; the
|
||||
// corner on the speaker's side is tightened into the same subtle tail.
|
||||
val isSelfInBubbles = bubbles && message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val imageShape = when {
|
||||
!bubbles -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
|
||||
isSelfInBubbles -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp, 10.dp, 3.dp, 10.dp)
|
||||
else -> androidx.compose.foundation.shape.RoundedCornerShape(10.dp, 10.dp, 10.dp, 3.dp)
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (isSelfInBubbles) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
@ -91,7 +104,10 @@ fun ImageMessageItem(
|
||||
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
|
||||
else -> null
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isSelfInBubbles) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
Box {
|
||||
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
|
||||
// Cyberpunk block-reveal while sending
|
||||
@ -103,7 +119,7 @@ fun ImageMessageItem(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.aspectRatio(aspect)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
|
||||
.clip(imageShape)
|
||||
.clickable {
|
||||
val currentIndex = imagePaths.indexOf(path)
|
||||
onImageClick?.invoke(path, imagePaths, currentIndex)
|
||||
@ -117,7 +133,7 @@ fun ImageMessageItem(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.aspectRatio(aspect)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
|
||||
.clip(imageShape)
|
||||
.clickable {
|
||||
val currentIndex = imagePaths.indexOf(path)
|
||||
onImageClick?.invoke(path, imagePaths, currentIndex)
|
||||
|
||||
@ -23,6 +23,7 @@ import androidx.compose.ui.unit.sp
|
||||
@Composable
|
||||
fun VoiceNotePlayer(
|
||||
path: String,
|
||||
modifier: Modifier = Modifier,
|
||||
progressOverride: Float? = null,
|
||||
progressColor: Color? = null
|
||||
) {
|
||||
@ -86,7 +87,7 @@ fun VoiceNotePlayer(
|
||||
DisposableEffect(Unit) { onDispose { try { player.release() } catch (_: Exception) {} } }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
|
||||
49
app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt
Normal file
49
app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt
Normal file
@ -0,0 +1,49 @@
|
||||
package com.bitchat.android.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Chat transcript presentation.
|
||||
*
|
||||
* [Matrix] is the established terminal-style transcript: a flat, left-aligned monochrome
|
||||
* stream where colour is reserved for `@names` and links.
|
||||
*
|
||||
* [Bubbles] is the classic messenger layout: messages hug their content inside rounded
|
||||
* bubbles, own messages on the right and everyone else on the left. Each bubble is tinted
|
||||
* with its author's stable peer colour, so the speaker stays identifiable without reading
|
||||
* the name. Colours, surfaces, and typography are untouched — only the message layout
|
||||
* changes.
|
||||
*/
|
||||
enum class ChatUiMode {
|
||||
Matrix,
|
||||
Bubbles;
|
||||
|
||||
val isMatrix: Boolean get() = this == Matrix
|
||||
val isBubbles: Boolean get() = this == Bubbles
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple SharedPreferences-backed manager for the chat UI mode with a StateFlow.
|
||||
* Mirrors [ThemePreferenceManager].
|
||||
*/
|
||||
object ChatUiModeManager {
|
||||
private const val PREFS_NAME = "bitchat_settings"
|
||||
private const val KEY_CHAT_UI_MODE = "chat_ui_mode"
|
||||
|
||||
private val _modeFlow = MutableStateFlow(ChatUiMode.Bubbles)
|
||||
val modeFlow: StateFlow<ChatUiMode> = _modeFlow
|
||||
|
||||
fun init(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val saved = prefs.getString(KEY_CHAT_UI_MODE, ChatUiMode.Bubbles.name)
|
||||
_modeFlow.value = runCatching { ChatUiMode.valueOf(saved!!) }.getOrDefault(ChatUiMode.Bubbles)
|
||||
}
|
||||
|
||||
fun set(context: Context, mode: ChatUiMode) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putString(KEY_CHAT_UI_MODE, mode.name).apply()
|
||||
_modeFlow.value = mode
|
||||
}
|
||||
}
|
||||
@ -37,6 +37,30 @@ internal object ChatVisualTokens {
|
||||
val SenderTopPadding: Dp = 8.dp
|
||||
val SenderToBodySpacing: Dp = 4.dp
|
||||
|
||||
// MARK: - Bubble geometry (ChatUiMode.Bubbles)
|
||||
|
||||
/** Rounded corner on the three "free" corners of a message bubble. */
|
||||
val BubbleCornerRadius: Dp = 16.dp
|
||||
|
||||
/** Tightened corner on the speaker's own side, giving the bubble a subtle tail. */
|
||||
val BubbleTailRadius: Dp = 4.dp
|
||||
|
||||
/** Padding inside a bubble, around the text. */
|
||||
val BubblePaddingHorizontal: Dp = 12.dp
|
||||
val BubblePaddingVertical: Dp = 8.dp
|
||||
|
||||
/** A bubble never grows past this fraction of the list width, so long lines still wrap. */
|
||||
const val BubbleMaxWidthFraction: Float = 0.80f
|
||||
|
||||
/**
|
||||
* Author-colour wash inside a bubble. Matches the mention-chip treatment so a tinted
|
||||
* bubble stays legible on both the near-black and near-white chat surfaces.
|
||||
*/
|
||||
const val BubbleBackgroundAlpha: Float = 0.18f
|
||||
|
||||
/** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */
|
||||
const val BubbleBorderAlpha: Float = 0.38f
|
||||
|
||||
const val SenderSuffixAlpha: Float = 0.60f
|
||||
const val HighlightAlpha: Float = 0.20f
|
||||
const val MutedTextAlpha: Float = 0.50f
|
||||
|
||||
@ -293,12 +293,6 @@
|
||||
<string name="media_type_file">File</string>
|
||||
|
||||
<!-- Mga status ng mensahe -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Tulong na text sa abiso -->
|
||||
<string name="notification_sent_image">📷 nagpadala ng larawan</string>
|
||||
|
||||
@ -294,12 +294,6 @@
|
||||
<string name="media_type_file">Fichier</string>
|
||||
|
||||
<!-- Statuts de message -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Aide de texte de notification -->
|
||||
<string name="notification_sent_image">📷 a envoyé une image</string>
|
||||
|
||||
@ -349,12 +349,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">תמונה</string>
|
||||
<string name="media_type_file">קובץ</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 שלח/ה תמונה</string>
|
||||
<string name="notification_sent_voice">🎤 שלח/ה הודעה קולית</string>
|
||||
<string name="notification_sent_file">📎 שלח/ה קובץ</string>
|
||||
|
||||
@ -338,12 +338,6 @@
|
||||
<string name="warning_emoji">⚠️</string>
|
||||
|
||||
<!-- Icone stato messaggio -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Helper file notifiche -->
|
||||
<string name="notification_file_pdf">📄</string>
|
||||
|
||||
@ -302,12 +302,6 @@
|
||||
<string name="media_type_file">Rakitra</string>
|
||||
|
||||
<!-- Famantarana toe-javatra hafatra -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Mpanampy lahatsoratra fampandrenesana -->
|
||||
<string name="notification_sent_image">📷 nandefasa sary</string>
|
||||
|
||||
@ -374,12 +374,6 @@
|
||||
<string name="media_type_file">Fail</string>
|
||||
|
||||
<!-- Message status icons -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Notification text helpers -->
|
||||
<string name="notification_sent_image">📷 menghantar imej</string>
|
||||
|
||||
@ -293,12 +293,6 @@
|
||||
<string name="media_type_file">फाइल</string>
|
||||
|
||||
<!-- सन्देश स्थिति -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- सूचना पाठ सहायक -->
|
||||
<string name="notification_sent_image">📷 तस्वीर पठाइयो</string>
|
||||
|
||||
@ -339,12 +339,6 @@
|
||||
<string name="debug_question_mark">?</string>
|
||||
|
||||
<!-- Berichtstatus-pictogrammen -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Meldingsbestand-emojis -->
|
||||
<string name="notification_file_pdf">📄</string>
|
||||
|
||||
@ -347,12 +347,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">Obraz</string>
|
||||
<string name="media_type_file">Plik</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 wysłał(a) obraz</string>
|
||||
<string name="notification_sent_voice">🎤 wysłał(a) wiadomość głosową</string>
|
||||
<string name="notification_sent_file">📎 wysłał(a) plik</string>
|
||||
|
||||
@ -276,13 +276,6 @@
|
||||
<string name="media_type_image">Изображение</string>
|
||||
<string name="media_type_file">Файл</string>
|
||||
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<string name="notification_sent_image">📷 отправил изображение</string>
|
||||
<string name="notification_sent_voice">🎤 отправил голосовое</string>
|
||||
<string name="notification_sent_file">📎 отправил файл</string>
|
||||
|
||||
@ -276,13 +276,6 @@
|
||||
<string name="media_type_image">Bild</string>
|
||||
<string name="media_type_file">Fil</string>
|
||||
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<string name="notification_sent_image">📷 skickade en bild</string>
|
||||
<string name="notification_sent_voice">🎤 skickade ett röstmeddelande</string>
|
||||
<string name="notification_sent_file">📎 skickade en fil</string>
|
||||
|
||||
@ -352,12 +352,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">படம்</string>
|
||||
<string name="media_type_file">கோப்பு</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 ஒரு படத்தை அனுப்பியது</string>
|
||||
<string name="notification_sent_voice">🎤 ஒரு குரல் செய்தியை அனுப்பியது</string>
|
||||
<string name="notification_sent_file">📎 ஒரு கோப்பை அனுப்பியது</string>
|
||||
|
||||
@ -276,13 +276,6 @@
|
||||
<string name="media_type_image">Görsel</string>
|
||||
<string name="media_type_file">Dosya</string>
|
||||
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<string name="notification_sent_image">📷 görsel gönderdi</string>
|
||||
<string name="notification_sent_voice">🎤 sesli mesaj gönderdi</string>
|
||||
<string name="notification_sent_file">📎 dosya gönderdi</string>
|
||||
|
||||
@ -341,12 +341,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">Зображення</string>
|
||||
<string name="media_type_file">Файл</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 надіслав(-ла) зображення</string>
|
||||
<string name="notification_sent_voice">🎤 надіслав(-ла) голосове повідомлення</string>
|
||||
<string name="notification_sent_file">📎 надіслав(-ла) файл</string>
|
||||
|
||||
@ -347,12 +347,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">图片</string>
|
||||
<string name="media_type_file">文件</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 发送了一张图片</string>
|
||||
<string name="notification_sent_voice">🎤 发送了一条语音消息</string>
|
||||
<string name="notification_sent_file">📎 发送了一个文件</string>
|
||||
|
||||
@ -347,12 +347,6 @@
|
||||
<string name="image_star">image/*</string>
|
||||
<string name="media_type_image">圖片</string>
|
||||
<string name="media_type_file">檔案</string>
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
<string name="notification_sent_image">📷 傳送了一張圖片</string>
|
||||
<string name="notification_sent_voice">🎤 傳送了一則語音訊息</string>
|
||||
<string name="notification_sent_file">📎 傳送了一個檔案</string>
|
||||
|
||||
@ -293,12 +293,6 @@
|
||||
<string name="media_type_file">文件</string>
|
||||
|
||||
<!-- 消息状态 -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- 通知文本辅助 -->
|
||||
<string name="notification_sent_image">📷 发送了图片</string>
|
||||
|
||||
@ -196,6 +196,8 @@
|
||||
<string name="about_system">System</string>
|
||||
<string name="about_light">Light</string>
|
||||
<string name="about_dark">Dark</string>
|
||||
<string name="chat_ui_matrix">Matrix</string>
|
||||
<string name="chat_ui_bubbles">Bubbles</string>
|
||||
<string name="about_pow">Proof of Work</string>
|
||||
<string name="about_pow_off">PoW Off</string>
|
||||
<string name="about_pow_on">PoW On</string>
|
||||
@ -556,14 +558,6 @@
|
||||
<string name="media_type_image">Image</string>
|
||||
<string name="media_type_file">File</string>
|
||||
|
||||
<!-- Message status icons -->
|
||||
<string name="status_sending">○</string>
|
||||
<string name="status_pending">○</string>
|
||||
<string name="status_sent">✓</string>
|
||||
<string name="status_delivered">✓✓</string>
|
||||
<string name="status_failed">⚠</string>
|
||||
<string name="status_read">✓</string>
|
||||
|
||||
<!-- Notification text helpers -->
|
||||
<string name="notification_sent_image">📷 sent an image</string>
|
||||
<string name="notification_sent_voice">🎤 sent a voice message</string>
|
||||
|
||||
@ -385,7 +385,7 @@ class ChatUIUtilsTest {
|
||||
palette = palette,
|
||||
)
|
||||
|
||||
assertEquals("@carol#04af", sender.text)
|
||||
assertEquals("@carol #04af", sender.text)
|
||||
|
||||
val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" }
|
||||
val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user