diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index fe3d924f..7b5926ef 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -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) { } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt index 3e1d7fe1..ac3b52f1 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/text/AnnotatedClickableText.kt @@ -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(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) + }, ) } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index cf0faaa1..845f59ca 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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) + ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index d2ab06d3..14c7879d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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), diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 5ab113db..402bb395 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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 = emptyList(), mentionPeerIdentities: Map = 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(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 { + 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 + } + ) } diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index de18baf0..a5ce8864 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 5d9908fc..ce7ba470 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -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, 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) diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 10109194..1f3f567e 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -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) ) { diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt new file mode 100644 index 00000000..36c9fb7f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatUiMode.kt @@ -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 = _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 + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt index cdd4d418..10ab9e9e 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -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 diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 66cd93bd..fa4aea0c 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -293,12 +293,6 @@ File - - - - ✓✓ - - 📷 nagpadala ng larawan diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aa857f44..a884d35e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -294,12 +294,6 @@ Fichier - - - - ✓✓ - - 📷 a envoyé une image diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 86e4bbcb..f742042e 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -349,12 +349,6 @@ image/* תמונה קובץ - - - - ✓✓ - - 📷 שלח/ה תמונה 🎤 שלח/ה הודעה קולית 📎 שלח/ה קובץ diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a8ecb688..af59fad3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -338,12 +338,6 @@ ⚠️ - - - - ✓✓ - - 📄 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 52617e72..b7182e09 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -302,12 +302,6 @@ Rakitra - - - - ✓✓ - - 📷 nandefasa sary diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index c6391e6f..79bf5b1c 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -374,12 +374,6 @@ Fail - - - - ✓✓ - - 📷 menghantar imej diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 7a3da0bd..59d52853 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -293,12 +293,6 @@ फाइल - - - - ✓✓ - - 📷 तस्वीर पठाइयो diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 64326ae5..1e46f196 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -339,12 +339,6 @@ ? - - - - ✓✓ - - 📄 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0d2f2689..3ec84dd4 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -347,12 +347,6 @@ image/* Obraz Plik - - - - ✓✓ - - 📷 wysłał(a) obraz 🎤 wysłał(a) wiadomość głosową 📎 wysłał(a) plik diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 613b008d..02b1175d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -276,13 +276,6 @@ Изображение Файл - - - - ✓✓ - - - 📷 отправил изображение 🎤 отправил голосовое 📎 отправил файл diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c0457997..b958faad 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -276,13 +276,6 @@ Bild Fil - - - - ✓✓ - - - 📷 skickade en bild 🎤 skickade ett röstmeddelande 📎 skickade en fil diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 5e7f8bdd..243f2112 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -352,12 +352,6 @@ image/* படம் கோப்பு - - - - ✓✓ - - 📷 ஒரு படத்தை அனுப்பியது 🎤 ஒரு குரல் செய்தியை அனுப்பியது 📎 ஒரு கோப்பை அனுப்பியது diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 533e3252..251d6a46 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -276,13 +276,6 @@ Görsel Dosya - - - - ✓✓ - - - 📷 görsel gönderdi 🎤 sesli mesaj gönderdi 📎 dosya gönderdi diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cdc73c91..f248ff71 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -341,12 +341,6 @@ image/* Зображення Файл - - - - ✓✓ - - 📷 надіслав(-ла) зображення 🎤 надіслав(-ла) голосове повідомлення 📎 надіслав(-ла) файл diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b9e35372..fca6b07b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -347,12 +347,6 @@ image/* 图片 文件 - - - - ✓✓ - - 📷 发送了一张图片 🎤 发送了一条语音消息 📎 发送了一个文件 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 36b6bbdd..f427dbaf 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -347,12 +347,6 @@ image/* 圖片 檔案 - - - - ✓✓ - - 📷 傳送了一張圖片 🎤 傳送了一則語音訊息 📎 傳送了一個檔案 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 751580a6..35f7477a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -293,12 +293,6 @@ 文件 - - - - ✓✓ - - 📷 发送了图片 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f705c5ad..a73b5bbb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,8 @@ System Light Dark + Matrix + Bubbles Proof of Work PoW Off PoW On @@ -556,14 +558,6 @@ Image File - - - - - ✓✓ - - - 📷 sent an image 🎤 sent a voice message diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 69693b7e..44377856 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -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" }