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:
callebtc 2026-08-01 21:31:57 +02:00 committed by GitHub
commit 2e943b2ebc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 527 additions and 198 deletions

View File

@ -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) { }

View File

@ -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)
},
)
}

View File

@ -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)
)
}
}
}
}

View File

@ -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),

View File

@ -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
}
)
}

View File

@ -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) {

View File

@ -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)

View File

@ -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)
) {

View 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
}
}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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" }