feat(chat): add bubbles chat UI mode with peer-color bubbles

Add a toggleable chat transcript style alongside the existing matrix
transcript. Bubbles mode renders text messages as classic messenger
bubbles: own messages on the right, peers on the left, each bubble
washed with the author's stable identity-derived peer colour so the
speaker stays identifiable without changing any theme, surface, or
background colours.

- ChatUiMode preference (Matrix / Bubbles, default Bubbles) persisted
  via ChatUiModeManager, mirroring ThemePreferenceManager
- BubbleTextMessageLayout: rounded bubble with a subtle tail on the
  speaker's side, width capped at 80% so long messages wrap; sender
  labels, grouping, timestamps, mentions, links, long-press, and the
  existing spring entry/placement animations are unchanged
- Delivery status for own private messages moves beneath the bubble in
  bubbles mode so it never overlaps the tail
- Chat style picker in About -> Settings reusing the ThemeChip pattern
This commit is contained in:
callebtc 2026-08-01 01:41:36 +02:00
parent 13f15ca1ef
commit 94d5fa3084
6 changed files with 290 additions and 5 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

@ -418,6 +418,40 @@ fun AboutSheet(
}
}
item(key = "chat_style") {
Column {
AboutSectionLabel(text = stringResource(R.string.about_section_chat_style))
val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState()
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = colorScheme.surface,
shape = AboutCardShape
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
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)
)
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)
)
}
}
}
}
item(key = "language") {
val selectedLanguageName = supportedLanguages
.firstOrNull { it.languageTag == selectedLanguageTag }

View File

@ -27,6 +27,7 @@ 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
@ -36,6 +37,7 @@ 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.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
@ -49,6 +51,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
@ -67,6 +70,7 @@ 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@ -83,10 +87,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 +229,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 +362,7 @@ fun MessagesList(
meshService = meshService,
mentionPeerIdentities = resolvedMentionPeerIdentities,
showSender = !isGrouped,
bubbles = bubbles.isBubbles,
topSpacing = MessageGrouping.topSpacingFor(
isGrouped = isGrouped,
isFirstInList = originalIndex == 0
@ -385,6 +396,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 +419,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 +432,7 @@ fun MessageItem(
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -429,8 +443,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 +457,21 @@ fun MessageItem(
}
}
}
// Bubble mode: a small end-aligned marker beneath the bubble, clear of the tail.
if (bubbles && 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 +487,7 @@ fun MessageItem(
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
showSender: Boolean,
bubbles: Boolean = false,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
@ -624,6 +654,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier
@ -668,6 +699,7 @@ fun MessageItem(
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
@ -687,6 +719,7 @@ internal fun TextMessageLayout(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true,
bubbles: Boolean = false,
bodyContent: String = message.content,
) {
val palette = LocalBitchatPalette.current
@ -731,6 +764,20 @@ internal fun TextMessageLayout(
onMessageLongPress?.invoke(message)
}
if (bubbles) {
BubbleTextMessageLayout(
message = message,
senderText = senderText,
bodyText = bodyText,
isSelf = isSelf,
showSender = showSender,
onNicknameClick = onNicknameClick,
onLongPress = handleLongPress,
modifier = modifier,
)
return
}
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
@ -788,6 +835,129 @@ 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,
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(),
verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start,
) {
if (showSender) {
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,
modifier = Modifier
.padding(
top = MessageGrouping.SENDER_TOP_PADDING,
// Nudge the label off the bubble's rounded edge so it lines up with the text.
start = if (isSelf) 0.dp else ChatVisualTokens.BubblePaddingHorizontal,
end = if (isSelf) ChatVisualTokens.BubblePaddingHorizontal else 0.dp,
),
fontFamily = BitchatFontFamily,
softWrap = false,
overflow = TextOverflow.Ellipsis,
style = MessageSenderTextStyle,
)
}
// 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
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,
)
) {
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,
fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
style = MessageBodyTextStyle.copy(color = MaterialTheme.colorScheme.onSurface),
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CashuMessageContent(
@ -797,6 +967,7 @@ private fun CashuMessageContent(
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
bubbles: Boolean = false,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
@ -816,6 +987,7 @@ private fun CashuMessageContent(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
bubbles = bubbles,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
bodyContent = remainingText,

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

@ -171,6 +171,7 @@
<!-- About sheet: section labels -->
<string name="about_section_about">About</string>
<string name="about_section_theme">Theme</string>
<string name="about_section_chat_style">Chat style</string>
<string name="about_section_settings">Settings</string>
<!-- About sheet: feature list -->
@ -196,6 +197,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>