first pass

This commit is contained in:
callebtc 2026-07-27 01:56:59 +02:00
parent 92d07b22fa
commit e1d07f99f5
49 changed files with 2547 additions and 1251 deletions

View File

@ -63,7 +63,9 @@ fun BitChatBrandButton(
imageVector = BitChatIcon,
contentDescription = contentDescription,
tint = tint,
modifier = Modifier.size(16.dp),
// 20.dp rather than 16.dp: the pixel-art bubble has heavy internal negative space,
// so it reads noticeably smaller than a solid glyph at the same nominal size.
modifier = Modifier.size(20.dp),
)
}
}

View File

@ -0,0 +1,622 @@
package com.bitchat.android.ui
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
import com.bitchat.android.core.ui.icon.BitChatIcon
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
/**
* Building blocks for the redesigned About sheet.
*
* Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for
* preferences, whereas these are pure presentation.
*/
/** Horizontal inset shared by every About section, so cards and labels align to one grid. */
internal val AboutHorizontalPadding = 20.dp
/** Card corner radius for grouped rows. */
internal val AboutCardShape = RoundedCornerShape(16.dp)
/** Two top-level views of the sheet. */
enum class AboutTab {
HowToUse,
BasicInfo,
}
/**
* Small uppercase section label, e.g. `SETTINGS`.
*
* Uppercasing happens here rather than in the string resource so translators supply natural
* sentence case and locales without a case distinction are unaffected.
*/
@Composable
internal fun AboutSectionLabel(
text: String,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
Text(
text = text.uppercase(),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.8.sp,
color = palette.textTertiary,
modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp)
)
}
/**
* Centered app identity block: logo, wordmark, tagline, version.
*/
@Composable
internal fun AboutHero(
versionName: String,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = BitChatIcon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.app_name),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 40.sp,
// Monospace at display size leaves too much air between glyphs; pull it in slightly
// so the wordmark reads as a single unit.
letterSpacing = (-0.5).sp,
color = colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.about_tagline),
fontFamily = FontFamily.Monospace,
fontSize = 16.sp,
color = palette.textSecondary
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.version_prefix, versionName),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = palette.textTertiary
)
}
}
/**
* Two-up tab bar with a sliding underline indicator.
*
* The indicator animates its offset rather than cross-fading two static bars, which is what
* makes the switch feel physically connected to the tap.
*/
@Composable
internal fun AboutTabBar(
selected: AboutTab,
onSelect: (AboutTab) -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val density = LocalDensity.current
var rowWidth by remember { mutableStateOf(0.dp) }
val tabWidth = rowWidth / 2
val indicatorOffset by animateDpAsState(
targetValue = if (selected == AboutTab.HowToUse) 0.dp else tabWidth,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "aboutTabIndicator"
)
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.onSizeChanged { size ->
rowWidth = with(density) { size.width.toDp() }
}
) {
Row(modifier = Modifier.fillMaxWidth()) {
AboutTabLabel(
text = stringResource(R.string.about_tab_how_to_use),
isSelected = selected == AboutTab.HowToUse,
onClick = { onSelect(AboutTab.HowToUse) },
modifier = Modifier.weight(1f)
)
AboutTabLabel(
text = stringResource(R.string.about_tab_basic_info),
isSelected = selected == AboutTab.BasicInfo,
onClick = { onSelect(AboutTab.BasicInfo) },
modifier = Modifier.weight(1f)
)
}
Box(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
Box(
modifier = Modifier
.offset(x = indicatorOffset)
.width(tabWidth)
.height(2.dp)
.background(colorScheme.primary)
)
}
}
}
@Composable
private fun AboutTabLabel(
text: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val color by animateColorAsState(
targetValue = if (isSelected) colorScheme.primary else palette.textSecondary,
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
label = "aboutTabLabelColor"
)
Box(
modifier = modifier
.height(44.dp)
.clickable(onClickLabel = text) { onClick() },
contentAlignment = Alignment.Center
) {
Text(
text = text.uppercase(),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.8.sp,
color = color
)
}
}
/** One line of the "How To Use" list: an icon plus a single instruction. */
@Composable
private fun AboutInstructionRow(
icon: ImageVector,
text: String
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(18.dp)
)
Text(
text = text,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
lineHeight = 20.sp,
color = palette.textPrimary
)
}
}
/**
* The "How To Use" tab: a short, scannable list of the gestures that are not self-evident.
*/
@Composable
internal fun AboutHowToUseSection(modifier: Modifier = Modifier) {
val colorScheme = MaterialTheme.colorScheme
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.about_how_to_use_heading),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = colorScheme.primary,
modifier = Modifier.padding(
start = AboutHorizontalPadding,
top = 20.dp,
bottom = 8.dp
)
)
AboutInstructionRow(
icon = Icons.Filled.AlternateEmail,
text = stringResource(R.string.about_howto_nickname)
)
AboutInstructionRow(
icon = Icons.Outlined.Public,
text = stringResource(R.string.about_howto_channels)
)
AboutInstructionRow(
icon = Icons.Filled.Groups,
text = stringResource(R.string.about_howto_people)
)
AboutInstructionRow(
icon = Icons.Outlined.BookmarkBorder,
text = stringResource(R.string.about_howto_bookmark)
)
AboutInstructionRow(
icon = Icons.Filled.AlternateEmail,
text = stringResource(R.string.about_howto_mention)
)
AboutInstructionRow(
icon = Icons.Filled.Terminal,
text = stringResource(R.string.about_howto_commands)
)
AboutInstructionRow(
icon = Icons.Filled.DeleteForever,
text = stringResource(R.string.about_howto_panic)
)
}
}
/**
* The six-item capability list from the design.
*
* Icons are Material approximations of the designer's custom line art; swap in the exported
* SVGs when they are available.
*/
@Composable
internal fun AboutFeatureCard(modifier: Modifier = Modifier) {
val palette = LocalBitchatPalette.current
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = palette.surface,
shape = AboutCardShape
) {
Column {
val features = listOf(
Triple(
Icons.Filled.WifiOff,
R.string.about_offline_comm_title,
R.string.about_offline_comm_desc
),
Triple(
Icons.Outlined.Lock,
R.string.about_e2e_title,
R.string.about_e2e_desc
),
Triple(
Icons.Outlined.Public,
R.string.about_local_channels_title,
R.string.about_local_channels_desc
),
Triple(
Icons.Outlined.VisibilityOff,
R.string.about_no_tracking_title,
R.string.about_no_tracking_desc
),
Triple(
Icons.Filled.Shuffle,
R.string.about_ephemeral_title,
R.string.about_ephemeral_desc
),
Triple(
Icons.Filled.DeleteForever,
R.string.about_panic_title,
R.string.about_panic_desc
),
)
features.forEachIndexed { index, (icon, titleRes, descRes) ->
if (index > 0) {
HorizontalDivider(
// Inset to align with the text column, not the icon.
modifier = Modifier.padding(start = 54.dp),
thickness = 1.dp,
color = palette.outlineVariant
)
}
AboutFeatureRow(
icon = icon,
title = stringResource(titleRes),
subtitle = stringResource(descRes)
)
}
}
}
}
@Composable
private fun AboutFeatureRow(
icon: ImageVector,
title: String,
subtitle: String
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 1.dp)
.size(22.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = palette.textPrimary
)
Text(
text = subtitle,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 17.sp,
color = palette.textSecondary
)
}
}
}
/**
* Security-audit warning banner.
*
* Deliberately styled as a tinted, outlined card rather than plain red text: it needs to be
* impossible to skim past, but it is also permanent until the audit lands, so a full-bleed
* alarm would quickly become wallpaper.
*/
@Composable
internal fun AboutWarningCard(modifier: Modifier = Modifier) {
val palette = LocalBitchatPalette.current
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = palette.accentRed.copy(alpha = 0.12f),
border = BorderStroke(1.dp, palette.accentRed.copy(alpha = 0.35f)),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = palette.accentRed,
modifier = Modifier.size(16.dp)
)
Text(
text = stringResource(R.string.about_warning_title).uppercase(),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
color = palette.accentRed
)
}
Text(
text = stringResource(R.string.about_warning_body),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 17.sp,
color = palette.accentRed.copy(alpha = 0.85f)
)
}
}
}
/**
* Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle.
*/
@Composable
internal fun BitchatBadge(
text: String,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
Box(
modifier = modifier
.background(palette.accentGreen.copy(alpha = 0.15f), RoundedCornerShape(4.dp))
.padding(horizontal = 5.dp, vertical = 2.dp)
) {
Text(
text = text.uppercase(),
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
color = palette.accentGreen
)
}
}
// MARK: - Shared bottom-sheet primitives
/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */
internal val SheetHorizontalPadding = 16.dp
/**
* Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`.
*/
@Composable
internal fun SheetSectionLabel(
text: String,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
Text(
text = text.uppercase(),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.8.sp,
color = palette.textTertiary,
modifier = modifier
.fillMaxWidth()
.padding(
start = SheetHorizontalPadding + 14.dp,
end = SheetHorizontalPadding,
top = 20.dp,
bottom = 6.dp
)
)
}
/**
* Circular tinted badge holding a section's icon, used at the top of the sheets.
*/
@Composable
internal fun SheetHeaderBadge(
icon: ImageVector,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Box(
modifier = modifier
.size(44.dp)
.background(palette.surface, androidx.compose.foundation.shape.CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
}
/**
* Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`.
*
* Tinted fill plus an outline rather than a solid red button: the action is legitimate but
* rarely wanted, and a solid red block would dominate the sheet.
*/
@Composable
internal fun SheetDestructiveButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isDestructive: Boolean = true
) {
val palette = LocalBitchatPalette.current
val accent = if (isDestructive) palette.accentRed else palette.accentGreen
Surface(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.height(44.dp),
shape = RoundedCornerShape(10.dp),
color = accent.copy(alpha = 0.10f),
border = BorderStroke(1.dp, accent.copy(alpha = 0.30f))
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = text.uppercase(),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.8.sp,
color = accent
)
}
}
}

View File

@ -1,6 +1,9 @@
package com.bitchat.android.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -35,6 +38,8 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
/**
* Feature row for displaying app capabilities
@ -46,11 +51,12 @@ private fun FeatureRow(
subtitle: String
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.Top
) {
Icon(
@ -58,22 +64,24 @@ private fun FeatureRow(
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.padding(top = 1.dp)
.size(22.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Spacer(modifier = Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
color = palette.textPrimary
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f),
lineHeight = 18.sp
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = palette.textSecondary,
lineHeight = 17.sp
)
}
}
@ -89,18 +97,26 @@ private fun ThemeChip(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val palette = LocalBitchatPalette.current
// Cross-fade the chip so switching theme does not read as two separate flashes (the chip
// recolouring plus the whole app recolouring underneath it).
val containerColor by animateColorAsState(
targetValue = if (selected) palette.accentGreen else palette.surfaceVariant,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "themeChipContainer"
)
val labelColor by animateColorAsState(
targetValue = if (selected) Color.White else palette.textSecondary,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "themeChipLabel"
)
Surface(
modifier = modifier,
onClick = onClick,
shape = RoundedCornerShape(10.dp),
color = if (selected) {
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
} else {
colorScheme.surfaceVariant.copy(alpha = 0.5f)
}
color = containerColor
) {
Box(
modifier = Modifier
@ -110,9 +126,10 @@ private fun ThemeChip(
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f)
color = labelColor
)
}
}
@ -133,23 +150,23 @@ private fun SettingsToggleRow(
statusIndicator: (@Composable () -> Unit)? = null
) {
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f),
tint = if (enabled) colorScheme.primary else palette.textTertiary,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
@ -160,31 +177,33 @@ private fun SettingsToggleRow(
) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f)
color = if (enabled) palette.textPrimary else palette.textTertiary
)
statusIndicator?.invoke()
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
lineHeight = 16.sp
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = if (enabled) palette.textSecondary else palette.textTertiary,
lineHeight = 17.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(
checked = checked,
onCheckedChange = { if (enabled) onCheckedChange(it) },
enabled = enabled,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
checkedTrackColor = palette.accentGreen,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = colorScheme.surfaceVariant
uncheckedTrackColor = palette.surfaceVariant
)
)
}
@ -221,12 +240,15 @@ fun AboutSheet(
}
val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.98f else 0f,
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
label = "topBarAlpha"
)
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val palette = LocalBitchatPalette.current
val isDark = palette.isDark
var selectedTab by remember { mutableStateOf(AboutTab.HowToUse) }
if (isPresented) {
BitchatBottomSheet(
modifier = modifier,
@ -236,103 +258,48 @@ fun AboutSheet(
LazyColumn(
state = lazyListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
// Header Section - App Identity
item(key = "header") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(R.string.app_name),
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
letterSpacing = 1.sp
),
color = colorScheme.onBackground
)
Text(
text = stringResource(R.string.version_prefix, versionName ?: ""),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.5f)
)
Text(
text = stringResource(R.string.about_tagline),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 4.dp)
)
item(key = "hero") {
AboutHero(versionName = versionName ?: "")
}
item(key = "tabs") {
AboutTabBar(
selected = selectedTab,
onSelect = { selectedTab = it },
modifier = Modifier.padding(top = 24.dp)
)
}
if (selectedTab == AboutTab.HowToUse) {
item(key = "how_to_use") {
AboutHowToUseSection()
}
}
if (selectedTab == AboutTab.BasicInfo) {
// Features Section - Grouped Card
item(key = "features") {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = stringResource(R.string.about_appearance).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) {
Column {
FeatureRow(
icon = Icons.Filled.Bluetooth,
title = stringResource(R.string.about_offline_mesh_title),
subtitle = stringResource(R.string.about_offline_mesh_desc)
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
FeatureRow(
icon = Icons.Default.Public,
title = stringResource(R.string.about_online_geohash_title),
subtitle = stringResource(R.string.about_online_geohash_desc)
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
FeatureRow(
icon = Icons.Default.Lock,
title = stringResource(R.string.about_e2e_title),
subtitle = stringResource(R.string.about_e2e_desc)
)
}
}
Column {
AboutSectionLabel(text = stringResource(R.string.about_section_about))
AboutFeatureCard()
}
}
// Appearance Section
item(key = "appearance") {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = "THEME",
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Column {
AboutSectionLabel(text = stringResource(R.string.about_section_theme))
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = palette.surface,
shape = AboutCardShape
) {
Row(
modifier = Modifier
@ -374,18 +341,14 @@ fun AboutSheet(
val torStatus by torProvider.statusFlow.collectAsState()
val torAvailable = remember { torProvider.isTorAvailable() }
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = "SETTINGS",
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Column {
AboutSectionLabel(text = stringResource(R.string.about_section_settings))
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = palette.surface,
shape = AboutCardShape
) {
Column {
// Background Mode Toggle
@ -404,12 +367,13 @@ fun AboutSheet(
}
}
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
modifier = Modifier.padding(start = 54.dp),
thickness = 1.dp,
color = palette.outlineVariant
)
// Proof of Work Toggle
SettingsToggleRow(
icon = Icons.Filled.Speed,
@ -418,16 +382,17 @@ fun AboutSheet(
checked = powEnabled,
onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) }
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
modifier = Modifier.padding(start = 54.dp),
thickness = 1.dp,
color = palette.outlineVariant
)
// Tor Toggle
SettingsToggleRow(
icon = Icons.Filled.Security,
title = "Tor Network",
title = stringResource(R.string.about_tor_title),
subtitle = stringResource(R.string.about_tor_route),
checked = torMode.value == TorMode.ON,
onCheckedChange = { enabled ->
@ -440,9 +405,9 @@ fun AboutSheet(
statusIndicator = if (torMode.value == TorMode.ON) {
{
val statusColor = when {
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
torStatus.running -> Color(0xFFFF9500)
else -> Color(0xFFFF3B30)
torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen
torStatus.running -> palette.accentOrange
else -> palette.accentRed
}
Surface(
color = statusColor,
@ -454,15 +419,18 @@ fun AboutSheet(
)
}
}
// Tor unavailable hint
if (!torAvailable) {
Text(
text = stringResource(R.string.tor_not_available_in_this_build),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.5f),
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
color = palette.textTertiary,
modifier = Modifier.padding(
start = AboutHorizontalPadding + 16.dp,
top = 8.dp
)
)
}
}
@ -472,13 +440,15 @@ fun AboutSheet(
item(key = "pow_slider") {
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
if (powEnabled) {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = palette.surface,
shape = AboutCardShape
) {
Column(
modifier = Modifier.padding(16.dp),
@ -490,30 +460,36 @@ fun AboutSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Difficulty",
style = MaterialTheme.typography.bodyMedium,
text = stringResource(R.string.about_difficulty),
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
color = palette.textPrimary
)
Text(
text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}",
style = MaterialTheme.typography.bodySmall,
text = stringResource(
R.string.about_difficulty_value,
powDifficulty,
NostrProofOfWork.estimateMiningTime(powDifficulty)
),
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f)
fontSize = 12.sp,
color = palette.textSecondary
)
}
Slider(
value = powDifficulty.toFloat(),
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
valueRange = 0f..32f,
steps = 31,
colors = SliderDefaults.colors(
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
thumbColor = palette.accentGreen,
activeTrackColor = palette.accentGreen,
inactiveTrackColor = palette.surfaceVariant
)
)
Text(
text = when {
powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none)
@ -526,7 +502,7 @@ fun AboutSheet(
},
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.5f)
color = palette.textTertiary
)
}
}
@ -539,13 +515,15 @@ fun AboutSheet(
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
val torProvider = remember { ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
if (torMode.value == TorMode.ON) {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = palette.surface,
shape = AboutCardShape
) {
Column(
modifier = Modifier.padding(16.dp),
@ -556,16 +534,21 @@ fun AboutSheet(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val statusColor = when {
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
torStatus.running -> Color(0xFFFF9500)
else -> Color(0xFFFF3B30)
torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen
torStatus.running -> palette.accentOrange
else -> palette.accentRed
}
Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {}
Text(
text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected",
style = MaterialTheme.typography.bodyMedium,
text = if (torStatus.running) {
stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent)
} else {
stringResource(R.string.about_tor_disconnected)
},
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
color = palette.textPrimary
)
}
if (torStatus.lastLogLine.isNotEmpty()) {
@ -573,7 +556,7 @@ fun AboutSheet(
text = torStatus.lastLogLine.take(120),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.5f),
color = palette.textTertiary,
maxLines = 2
)
}
@ -583,49 +566,22 @@ fun AboutSheet(
}
}
// Emergency Warning
// Security audit warning
item(key = "warning") {
Surface(
modifier = Modifier
.padding(horizontal = 20.dp)
.fillMaxWidth(),
color = colorScheme.error.copy(alpha = 0.1f),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.about_emergency_title),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = colorScheme.error
)
Text(
text = stringResource(R.string.about_emergency_tip),
fontSize = 13.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
AboutWarningCard(modifier = Modifier.padding(top = 24.dp))
}
} // end BasicInfo tab
// Footer
item(key = "footer") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
.padding(
start = AboutHorizontalPadding,
end = AboutHorizontalPadding,
top = 24.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
@ -641,9 +597,9 @@ fun AboutSheet(
}
Text(
text = stringResource(R.string.about_footer),
fontSize = 12.sp,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.4f)
color = palette.textTertiary
)
Spacer(modifier = Modifier.height(20.dp))
}
@ -656,7 +612,7 @@ fun AboutSheet(
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(64.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
.background(palette.background.copy(alpha = topBarAlpha))
) {
CloseButton(
onClick = onDismiss,

View File

@ -2,20 +2,27 @@ package com.bitchat.android.ui
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
@ -30,28 +37,71 @@ import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
/**
* Header components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
/** Height of the chat top bar. Taller than the old 42.dp so 40.dp tap targets fit properly. */
val ChatHeaderHeight = 48.dp
/** Standard icon size in the header. */
private val HeaderIconSize = 18.dp
/** Minimum tap target for every interactive element in the header. */
private val HeaderTapTarget = 40.dp
/** Corner radius for the header's tappable label+icon clusters. */
private val HeaderClusterShape = RoundedCornerShape(8.dp)
/**
* A minimum-48x40 tap target wrapping a small icon.
*
* The old header used bare 16.dp icons with `Modifier.clickable`, which produced tap targets far
* below the accessibility minimum and made the channel/bookmark controls genuinely hard to hit.
*/
@Composable
private fun HeaderIconButton(
onClick: () -> Unit,
contentDescription: String?,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.size(HeaderTapTarget)
.clip(CircleShape)
.clickable(onClickLabel = contentDescription) { onClick() },
contentAlignment = Alignment.Center
) {
content()
}
}
@Composable
fun TorStatusDot(
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
val torStatus by torProvider.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val dotColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
else -> Color.Red // Red - error/disconnected
val targetColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> palette.accentOrange
torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen
else -> palette.accentRed
}
// Cross-fade rather than snap: Tor flips through bootstrapping states frequently and a
// hard colour cut in the corner of the screen reads as a glitch.
val dotColor by animateColorAsState(
targetValue = targetColor,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "torDotColor"
)
Canvas(
modifier = modifier
) {
@ -125,7 +175,7 @@ fun NicknameEditor(
Text(
text = stringResource(R.string.at_symbol),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary.copy(alpha = 0.8f)
color = colorScheme.primary.copy(alpha = 0.7f)
)
BasicTextField(
@ -144,7 +194,7 @@ fun NicknameEditor(
}
),
modifier = Modifier
.widthIn(max = 120.dp)
.widthIn(max = 140.dp)
.horizontalScroll(scrollState)
)
}
@ -161,45 +211,56 @@ fun PeerCounter(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
// Compute channel-aware people count and color (matches iOS logic exactly)
val (peopleCount, countColor) = when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> {
// Geohash channel: show geohash participants
val count = geohashPeople.size
val green = Color(0xFF00C851) // Standard green
Pair(count, if (count > 0) green else Color.Gray)
Pair(count, if (count > 0) palette.accentGreen else palette.textTertiary)
}
is com.bitchat.android.geohash.ChannelID.Mesh,
null -> {
// Mesh channel: show Bluetooth-connected peers (excluding self)
val count = connectedPeers.size
val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh
Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray)
Pair(count, if (isConnected && count > 0) palette.accentBlue else palette.textTertiary)
}
}
// Peers come and go constantly; fading the tint avoids a flicker every time the count
// crosses zero.
val animatedCountColor by animateColorAsState(
targetValue = countColor,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "peerCountColor"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
.clip(HeaderClusterShape)
.clickable { onClick() }
.height(HeaderTapTarget)
.padding(horizontal = 6.dp)
) {
Icon(
imageVector = Icons.Default.Group,
// `Groups` reads as a crowd at 18.dp, where `Group`'s two-person silhouette turns
// into an indistinct blob.
imageVector = Icons.Filled.Groups,
contentDescription = when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
else -> stringResource(R.string.cd_connected_peers)
},
modifier = Modifier.size(16.dp),
tint = countColor
modifier = Modifier.size(HeaderIconSize),
tint = animatedCountColor
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "$peopleCount",
style = MaterialTheme.typography.bodyMedium,
color = countColor,
fontSize = 16.sp,
color = animatedCountColor,
fontWeight = FontWeight.Medium
)
@ -207,8 +268,7 @@ fun PeerCounter(
Text(
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
style = MaterialTheme.typography.bodyMedium,
color = if (isConnected) Color(0xFF00C851) else Color.Red,
fontSize = 16.sp,
color = if (isConnected) palette.accentGreen else palette.accentRed,
fontWeight = FontWeight.Medium
)
}
@ -266,48 +326,36 @@ private fun ChannelHeader(
onSidebarClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Box(modifier = Modifier.fillMaxWidth()) {
// Back button - positioned all the way to the left with minimal margin
Button(
// Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that
// the old "< back" label consumed.
HeaderIconButton(
onClick = onBackClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
modifier = Modifier
.align(Alignment.CenterStart)
.offset(x = (-8).dp) // Move even further left to minimize margin
contentDescription = stringResource(R.string.back),
modifier = Modifier.align(Alignment.CenterStart)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.chat_back),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
modifier = Modifier.size(20.dp),
tint = colorScheme.primary
)
}
// Title - perfectly centered regardless of other elements
Text(
text = stringResource(R.string.chat_channel_prefix, channel),
text = "#$channel",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF9500), // Orange to match input field
color = colorScheme.primary,
modifier = Modifier
.align(Alignment.Center)
.clip(HeaderClusterShape)
.clickable { onSidebarClick() }
.padding(horizontal = 8.dp, vertical = 4.dp)
)
// Leave button - positioned on the right
TextButton(
onClick = onLeaveChannel,
@ -315,8 +363,8 @@ private fun ChannelHeader(
) {
Text(
text = stringResource(R.string.chat_leave),
style = MaterialTheme.typography.bodySmall,
color = Color.Red
style = MaterialTheme.typography.labelMedium,
color = palette.accentRed
)
}
}
@ -334,6 +382,7 @@ private fun MainHeader(
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
@ -349,80 +398,49 @@ private fun MainHeader(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
BitChatBrandButton(
onClick = onTitleClick,
onTripleClick = onTripleTitleClick,
contentDescription = stringResource(R.string.cd_open_about),
)
// MARK: - Identity cluster
BitChatBrandButton(
onClick = onTitleClick,
onTripleClick = onTripleTitleClick,
contentDescription = stringResource(R.string.cd_open_about),
)
Text(
text = "/",
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary,
)
Text(
text = "/",
style = MaterialTheme.typography.bodyMedium,
// Dimmed: the slash is a separator, not content. At full brightness it competed
// with the nickname beside it.
color = colorScheme.primary.copy(alpha = 0.45f),
modifier = Modifier.padding(horizontal = 2.dp)
)
Spacer(modifier = Modifier.width(2.dp))
NicknameEditor(
value = nickname,
onValueChange = onNicknameChange
)
}
// Right section with location channels button and peer counter
NicknameEditor(
value = nickname,
onValueChange = onNicknameChange
)
Spacer(modifier = Modifier.weight(1f))
// MARK: - Status cluster. Order matches the design: bookmark, channel, people.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp)
// Tight, because every child below is its own >=40.dp tap target.
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
// Unread private messages badge (click to open most recent DM)
if (hasUnreadPrivateMessages.isNotEmpty()) {
// Render icon directly to avoid symbol resolution issues
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_private_messages),
modifier = Modifier
.size(16.dp)
.clickable { viewModel.openLatestUnreadPrivateChat() },
tint = Color(0xFFFF9500)
)
}
// Location channels button (matching iOS implementation) and bookmark grouped tightly
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
LocationChannelsButton(
viewModel = viewModel,
onClick = onLocationChannelsClick
)
// Bookmark toggle for current geohash (not shown for mesh)
val currentGeohash: String? = when (val sc = selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
else -> null
}
if (currentGeohash != null) {
val isBookmarked = bookmarks.contains(currentGeohash)
Box(
modifier = Modifier
.padding(start = 2.dp) // minimal gap between geohash and bookmark
.size(20.dp)
.clickable { bookmarksStore.toggle(currentGeohash) },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = stringResource(R.string.cd_toggle_bookmark),
tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
modifier = Modifier.size(16.dp)
)
}
HeaderIconButton(
onClick = { viewModel.openLatestUnreadPrivateChat() },
contentDescription = stringResource(R.string.cd_unread_private_messages)
) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_private_messages),
modifier = Modifier.size(HeaderIconSize),
tint = palette.accentOrange
)
}
}
@ -432,19 +450,43 @@ private fun MainHeader(
onClick = onLocationNotesClick
)
// Tor status dot when Tor is enabled
TorStatusDot(
modifier = Modifier
.size(8.dp)
.padding(start = 0.dp, end = 2.dp)
)
// Tor status dot. Wrapped in a fixed box so its optical position no longer depends
// on ad-hoc asymmetric padding.
Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) {
TorStatusDot(modifier = Modifier.size(6.dp))
}
// PoW status indicator
PoWStatusIndicator(
modifier = Modifier,
style = PoWIndicatorStyle.COMPACT
)
Spacer(modifier = Modifier.width(2.dp))
// Bookmark toggle for current geohash (not shown for mesh)
val currentGeohash: String? = when (val sc = selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
else -> null
}
if (currentGeohash != null) {
val isBookmarked = bookmarks.contains(currentGeohash)
HeaderIconButton(
onClick = { bookmarksStore.toggle(currentGeohash) },
contentDescription = stringResource(R.string.cd_toggle_bookmark)
) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = stringResource(R.string.cd_toggle_bookmark),
tint = if (isBookmarked) palette.accentGreen else palette.textSecondary,
modifier = Modifier.size(HeaderIconSize)
)
}
}
LocationChannelsButton(
viewModel = viewModel,
onClick = onLocationChannelsClick
)
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
joinedChannels = joinedChannels,
@ -458,56 +500,64 @@ private fun MainHeader(
}
}
/**
* Current channel indicator: a globe for geohash channels, a Bluetooth glyph for the local mesh.
*
* The design brief asked for the "addition of globe icon to represent channels". Previously this
* was a text-only badge wrapped in an M3 [Button], which imposed a hidden 58.dp minimum width
* and 40.dp minimum height that fought the header's explicit sizing.
*/
@Composable
private fun LocationChannelsButton(
viewModel: ChatViewModel,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
// Get current channel selection from location manager
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val (badgeText, badgeColor) = when (selectedChannel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
"#mesh" to Color(0xFF007AFF) // iOS blue for mesh
}
is com.bitchat.android.geohash.ChannelID.Location -> {
val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash
"#$geohash" to Color(0xFF00C851) // Green for location
}
null -> "#mesh" to Color(0xFF007AFF) // Default to mesh
val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location
val badgeText = when (val channel = selectedChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}"
else -> "#mesh"
}
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = badgeColor
),
contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp)
val badgeColor = if (isLocation) palette.accentGreen else palette.accentBlue
val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.clip(HeaderClusterShape)
.clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() }
.height(HeaderTapTarget)
.padding(horizontal = 6.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = badgeText,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = badgeColor,
maxLines = 1
Icon(
imageVector = badgeIcon,
contentDescription = null,
modifier = Modifier.size(HeaderIconSize),
tint = badgeColor
)
Text(
text = badgeText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = badgeColor,
maxLines = 1
)
// Teleportation indicator (like iOS)
if (teleported) {
Icon(
imageVector = Icons.Default.PinDrop,
contentDescription = stringResource(R.string.cd_teleported),
modifier = Modifier.size(12.dp),
tint = badgeColor
)
// Teleportation indicator (like iOS)
if (teleported) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Default.PinDrop,
contentDescription = stringResource(R.string.cd_teleported),
modifier = Modifier.size(12.dp),
tint = badgeColor
)
}
}
}
}

View File

@ -5,6 +5,8 @@ package com.bitchat.android.ui
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
@ -26,6 +28,8 @@ import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.media.FullScreenImageViewer
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
/**
* Main ChatScreen - REFACTORED to use component-based architecture
@ -40,6 +44,7 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
@ -114,7 +119,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
.fillMaxSize()
.background(colorScheme.background) // Extend background to fill entire screen including status bar
) {
val headerHeight = 42.dp
val headerHeight = ChatHeaderHeight
// Main content area that responds to keyboard/window insets
Column(
@ -263,14 +268,23 @@ fun ChatScreen(viewModel: ChatViewModel) {
.windowInsetsPadding(WindowInsets.statusBars)
.offset(y = headerHeight)
.zIndex(1f),
color = colorScheme.outline.copy(alpha = 0.3f)
thickness = 1.dp,
color = palette.outlineVariant
)
// Scroll-to-bottom floating button
AnimatedVisibility(
visible = isScrolledUp,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
// Short and eased: the button appears mid-scroll, so a slow entrance draws the eye
// away from the messages the user is actually reading.
enter = slideInVertically(
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
initialOffsetY = { it / 2 }
) + fadeIn(tween(BitchatMotion.STANDARD_MS)),
exit = slideOutVertically(
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
targetOffsetY = { it / 2 }
) + fadeOut(tween(BitchatMotion.QUICK_MS)),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 64.dp)
@ -280,16 +294,16 @@ fun ChatScreen(viewModel: ChatViewModel) {
) {
Surface(
shape = CircleShape,
color = colorScheme.background,
color = palette.surface,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
border = BorderStroke(2.dp, Color(0xFF00C851))
border = BorderStroke(1.dp, palette.accentGreen)
) {
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
tint = Color(0xFF00C851)
tint = palette.accentGreen
)
}
}
@ -394,12 +408,14 @@ fun ChatInputSection(
colorScheme: ColorScheme,
showMediaButtons: Boolean
) {
val palette = LocalBitchatPalette.current
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.background
) {
// No divider above the composer: the pill's own border provides the separation, and a
// full-width rule on top of it read as a double line.
Column {
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
// Command suggestions box
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
CommandSuggestionsBox(
@ -407,7 +423,7 @@ fun ChatInputSection(
onSuggestionClick = onCommandSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
}
// Mention suggestions box
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
@ -416,7 +432,7 @@ fun ChatInputSection(
onSuggestionClick = onMentionSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
}
MessageInput(
value = messageText,
@ -434,7 +450,6 @@ fun ChatInputSection(
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatFloatingHeader(
headerHeight: Dp,
@ -459,35 +474,38 @@ private fun ChatFloatingHeader(
.windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area
color = colorScheme.background // Solid background color extending into status bar
) {
TopAppBar(
title = {
ChatHeaderContent(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
onBackClick = {
when {
selectedPrivatePeer != null -> viewModel.endPrivateChat()
currentChannel != null -> viewModel.switchToChannel(null)
}
},
onSidebarClick = onSidebarToggle,
onTripleClick = onPanicClear,
onShowAppInfo = onShowAppInfo,
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = {
// Ensure location is loaded before showing sheet
locationManager.refreshChannels()
onLocationNotesClick()
// A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal
// pad plus a 12.dp title inset and applies its own minimum heights, which made the
// header's spacing impossible to specify exactly.
Row(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.padding(start = 12.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ChatHeaderContent(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
onBackClick = {
when {
selectedPrivatePeer != null -> viewModel.endPrivateChat()
currentChannel != null -> viewModel.switchToChannel(null)
}
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
modifier = Modifier.height(headerHeight) // Ensure compact header height
)
},
onSidebarClick = onSidebarToggle,
onTripleClick = onPanicClear,
onShowAppInfo = onShowAppInfo,
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = {
// Ensure location is loaded before showing sheet
locationManager.refreshChannels()
onLocationNotesClick()
}
)
}
}
}

View File

@ -8,8 +8,8 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.MeshService
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatPalette
import java.text.SimpleDateFormat
import java.util.*
@ -18,6 +18,15 @@ import java.util.*
* Extracted from ChatScreen.kt for better organization
*/
/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */
internal const val SUFFIX_ALPHA = 0.55f
/** Background opacity for a mention chip referring to somebody else. */
internal const val MENTION_CHIP_ALPHA = 0.18f
/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */
internal const val MENTION_CHIP_ALPHA_SELF = 0.22f
/**
* Get RSSI-based color for signal strength visualization
*/
@ -32,146 +41,21 @@ fun getRSSIColor(rssi: Int): Color {
}
/**
* Format message as annotated string with iOS-style formatting
* Timestamp at END, peer colors, hashtag suffix handling
*/
fun formatMessageAsAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
// Determine if this message was sent by self
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
if (message.sender != "system") {
// Get base color for this peer (iOS-style color assignment)
val baseColor = if (isSelf) {
Color(0xFFFF9500) // Orange for self (iOS orange)
} else {
getPeerColor(message, isDark)
}
// Split sender into base name and hashtag suffix
val (baseName, suffix) = splitSuffix(message.sender)
// Sender prefix "<@"
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append("<@")
builder.pop()
// Base name (clickable)
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
val nicknameStart = builder.length
val truncatedBase = truncateNickname(baseName)
builder.append(truncatedBase)
val nicknameEnd = builder.length
// Add click annotation for nickname (store canonical sender name with hash if available)
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = (message.originalSender ?: message.sender),
start = nicknameStart,
end = nicknameEnd
)
}
builder.pop()
// Hashtag suffix in lighter color (iOS style)
if (suffix.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = baseColor.copy(alpha = 0.6f),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append(suffix)
builder.pop()
}
// Sender suffix "> "
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append("> ")
builder.pop()
// Message content with iOS-style hashtag and mention highlighting
appendIOSFormattedContent(
builder,
message.content,
message.mentions,
currentUserNickname,
baseColor,
isSelf,
)
// iOS-style timestamp at the END (smaller, grey)
// Timestamp (and optional PoW badge)
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
// If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing
message.powDifficulty?.let { bits ->
if (bits > 0) {
builder.append(" ⛨${bits}b")
}
}
builder.pop()
} else {
// System message - iOS style
builder.pushStyle(SpanStyle(
color = Color.Gray,
fontSize = (BASE_FONT_SIZE - 2).sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
))
builder.append("* ${message.content} *")
builder.pop()
// Timestamp for system messages too
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.5f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
builder.pop()
}
return builder.toAnnotatedString()
}
/**
* Build the sender label used by the two-row text-message layout.
* 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.
*/
fun formatTextMessageSender(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme
palette: BitchatPalette
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark =
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark)
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
val (baseName, suffix) = splitSuffix(message.sender)
builder.pushStyle(
@ -198,7 +82,7 @@ fun formatTextMessageSender(
if (suffix.isNotEmpty()) {
builder.pushStyle(
SpanStyle(
color = senderColor.copy(alpha = 0.6f),
color = senderColor.copy(alpha = SUFFIX_ALPHA),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = senderWeight
)
@ -212,6 +96,9 @@ fun formatTextMessageSender(
/**
* Build the compact timestamp and optional proof-of-work label.
*
* Used standalone by media rows; text messages get the same span appended inline to the end of
* their body via [appendTrailingTimestamp].
*/
fun formatTextMessageMetadata(
message: BitchatMessage,
@ -233,66 +120,124 @@ fun formatTextMessageMetadata(
}
/**
* Build only the message body while retaining mention, URL and geohash styling.
* Append the timestamp (and optional PoW difficulty) directly after the message body so it
* trails the final words rather than occupying its own column.
*
* Deliberately carries no click annotation: the timestamp is decoration, and making it
* tappable would create dead zones inside the message body.
*/
private fun appendTrailingTimestamp(
builder: AnnotatedString.Builder,
message: BitchatMessage,
palette: BitchatPalette,
timeFormatter: SimpleDateFormat
) {
builder.pushStyle(
SpanStyle(
color = palette.textTertiary,
fontSize = (BASE_FONT_SIZE - 4).sp,
fontWeight = FontWeight.Normal
)
)
builder.append(" ")
builder.append(timeFormatter.format(message.timestamp))
message.powDifficulty?.takeIf { it > 0 }?.let { bits ->
builder.append(" ⛨${bits}b")
}
builder.pop()
}
/**
* Build the message body: neutral text with mention/URL/geohash accents, followed by an inline
* trailing timestamp.
*
* Body text is intentionally neutral rather than peer-colored. Colour is reserved for
* `@names`, which is what makes a busy channel scannable.
*/
fun formatTextMessageBody(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme
palette: BitchatPalette,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()),
includeTimestamp: Boolean = true
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark =
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
appendIOSFormattedContent(
builder = builder,
content = message.content,
mentions = message.mentions,
currentUserNickname = currentUserNickname,
baseColor = accentColor,
isSelf = isSelf,
contentColor = colorScheme.onSurface
palette = palette,
)
if (includeTimestamp) {
appendTrailingTimestamp(builder, message, palette, timeFormatter)
}
return builder.toAnnotatedString()
}
/**
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
* Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`.
*
* The `//` prefix reads as machine narration in a monospace context and is far quieter than
* the previous `* italic asterisk *` treatment, which competed with real messages.
*/
fun formatSystemMessage(
message: BitchatMessage,
palette: BitchatPalette,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
val builder = AnnotatedString.Builder()
builder.pushStyle(
SpanStyle(
color = palette.textSecondary,
fontSize = (BASE_FONT_SIZE - 2).sp,
fontWeight = FontWeight.Normal
)
)
builder.append("// ")
builder.append(message.content)
builder.pop()
appendTrailingTimestamp(builder, message, palette, timeFormatter)
return builder.toAnnotatedString()
}
/**
* Header line for media (image / audio / file) rows.
*
* Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an
* inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on
* the same line as the name.
*/
fun formatMessageHeaderAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
palette: BitchatPalette,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()),
includeSender: Boolean = true
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
if (message.sender != "system") {
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
if (message.sender == "system") {
return formatSystemMessage(message, palette, timeFormatter)
}
if (includeSender) {
val baseColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark)
val weight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
val (baseName, suffix) = splitSuffix(message.sender)
// "<@"
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append("<@")
builder.pop()
// Base name (clickable when not self)
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.pushStyle(
SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = weight
)
)
builder.append("@")
val nicknameStart = builder.length
builder.append(truncateNickname(baseName))
val nicknameEnd = builder.length
@ -306,53 +251,20 @@ fun formatMessageHeaderAnnotatedString(
}
builder.pop()
// Hashtag suffix
if (suffix.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = baseColor.copy(alpha = 0.6f),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.pushStyle(
SpanStyle(
color = baseColor.copy(alpha = SUFFIX_ALPHA),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = weight
)
)
builder.append(suffix)
builder.pop()
}
// Sender suffix ">"
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append(">")
builder.pop()
// Timestamp and optional PoW bits, matching normal message appearance
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
message.powDifficulty?.let { bits ->
if (bits > 0) builder.append(" ⛨${bits}b")
}
builder.pop()
} else {
// System message header (should rarely apply to voice)
builder.pushStyle(SpanStyle(
color = Color.Gray,
fontSize = (BASE_FONT_SIZE - 2).sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
))
builder.append("* ${message.content} *")
builder.pop()
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.5f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
builder.pop()
}
appendTrailingTimestamp(builder, message, palette, timeFormatter)
return builder.toAnnotatedString()
}
@ -385,7 +297,12 @@ fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color {
}
/**
* Generate consistent peer color using djb2 hash (matches iOS algorithm exactly)
* Generate consistent peer color using djb2 hash.
*
* The hash and hue derivation are byte-identical to the iOS implementation, so a given peer
* resolves to the same hue on both platforms. Saturation and value are deliberately higher
* than iOS: the redesigned chat surface renders message bodies in neutral near-white, so
* nicknames need more chroma to stay distinguishable at a glance.
*/
fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
// djb2 hash algorithm (matches iOS implementation)
@ -402,8 +319,8 @@ fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
hue = (hue + 0.12) % 1.0
}
val saturation = if (isDark) 0.50 else 0.70
val brightness = if (isDark) 0.85 else 0.35
val saturation = if (isDark) 0.62 else 0.85
val brightness = if (isDark) 0.92 else 0.45
return Color.hsv(
hue = (hue * 360).toFloat(),
@ -430,17 +347,27 @@ fun splitSuffix(name: String): Pair<String, String> {
}
/**
* iOS-style content formatting with proper hashtag and mention handling
* iOS-style content formatting with proper hashtag and mention handling.
*
* Redesign notes:
* - Plain text renders in [BitchatPalette.textPrimary]; colour is reserved for `@mentions`,
* links and geohashes.
* - Mentions get a tinted background chip so they read as a distinct token inside a sentence.
* The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks
* the same everywhere she is referenced.
* - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire
* paragraphs was the single biggest source of visual noise in the old layout; the mention
* chip carries that emphasis instead.
*/
private fun appendIOSFormattedContent(
builder: AnnotatedString.Builder,
content: String,
mentions: List<String>?,
currentUserNickname: String,
baseColor: Color,
isSelf: Boolean,
contentColor: Color = baseColor,
palette: BitchatPalette,
) {
val contentColor = palette.textPrimary
val linkColor = palette.accentBlue
// iOS-style patterns: allow optional '#abcd' suffix in mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
@ -507,28 +434,28 @@ private fun appendIOSFormattedContent(
}
allMatches.sortBy { it.first.first }
val plainStyle = SpanStyle(
color = contentColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = FontWeight.Normal
)
val linkStyle = SpanStyle(
color = linkColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = FontWeight.Normal,
textDecoration = TextDecoration.Underline
)
var lastEnd = 0
val isMentioned = mentions?.contains(currentUserNickname) == true
for ((range, type) in allMatches) {
// Add text before match
if (lastEnd < range.first) {
val beforeText = content.substring(lastEnd, range.first)
if (beforeText.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = contentColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
))
if (isMentioned) {
// Make entire message bold if user is mentioned
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
builder.append(beforeText)
builder.pop()
} else {
builder.append(beforeText)
}
builder.pushStyle(plainStyle)
builder.append(beforeText)
builder.pop()
}
}
@ -540,65 +467,55 @@ private fun appendIOSFormattedContent(
// iOS-style mention with hashtag suffix support
val mentionWithoutAt = matchText.removePrefix("@")
val (mBase, mSuffix) = splitSuffix(mentionWithoutAt)
// Check if this mention targets current user
// Mentions targeting you are the one thing worth shouting about.
val isMentionToMe = mBase == currentUserNickname
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
// "@" symbol
val mentionColor = if (isMentionToMe) {
palette.accentOrange
} else {
// Tint by the *mentioned* peer so a given name looks identical everywhere.
colorForPeerSeed(mentionWithoutAt.lowercase(), palette.isDark)
}
val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA
val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold
// A single outer span carrying the background makes the chip render as one
// continuous rectangle. Pushing the background per-token would leave hairline
// seams between "@", the name and the "#abcd" suffix.
builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha)))
builder.pushStyle(SpanStyle(
color = mentionColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
fontWeight = mentionWeight
))
builder.append("@")
builder.pop()
// Base name (truncate for rendering)
builder.pushStyle(SpanStyle(
color = mentionColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
))
builder.append(truncateNickname(mBase))
builder.pop()
// Hashtag suffix in lighter color
if (mSuffix.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = mentionColor.copy(alpha = 0.6f),
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
fontWeight = mentionWeight
))
builder.append(mSuffix)
builder.pop()
}
builder.pop() // background chip
}
"hashtag" -> {
// Render general hashtags like normal content
builder.pushStyle(SpanStyle(
color = contentColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
))
if (isMentioned) {
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
builder.append(matchText)
builder.pop()
} else {
builder.append(matchText)
}
builder.pushStyle(plainStyle)
builder.append(matchText)
builder.pop()
}
else -> {
if (type == "geohash") {
// Style geohash in blue, underlined, and add click annotation
builder.pushStyle(SpanStyle(
color = Color(0xFF007AFF),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
))
// Style geohash as a link and add click annotation
builder.pushStyle(linkStyle)
val start = builder.length
builder.append(matchText)
val end = builder.length
@ -611,13 +528,8 @@ private fun appendIOSFormattedContent(
)
builder.pop()
} else if (type == "url") {
// Style URL in blue, underlined, and add click annotation with the raw text
builder.pushStyle(SpanStyle(
color = Color(0xFF007AFF),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
))
// Style URL as a link and add click annotation with the raw text
builder.pushStyle(linkStyle)
val start = builder.length
builder.append(matchText)
val end = builder.length
@ -630,11 +542,7 @@ private fun appendIOSFormattedContent(
builder.pop()
} else {
// Fallback: treat as normal text
builder.pushStyle(SpanStyle(
color = contentColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
))
builder.pushStyle(plainStyle)
builder.append(matchText)
builder.pop()
}
@ -647,18 +555,8 @@ private fun appendIOSFormattedContent(
// Add remaining text
if (lastEnd < content.length) {
val remainingText = content.substring(lastEnd)
builder.pushStyle(SpanStyle(
color = contentColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
))
if (isMentioned) {
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
builder.append(remainingText)
builder.pop()
} else {
builder.append(remainingText)
}
builder.pushStyle(plainStyle)
builder.append(remainingText)
builder.pop()
}
}

View File

@ -3,6 +3,7 @@ package com.bitchat.android.ui
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Explore
@ -17,6 +18,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.util.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
@ -52,41 +54,21 @@ fun GeohashPeopleList(
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
Column {
// Header matching iOS style
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(R.string.geohash_people_header),
style = MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
),
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
val palette = LocalBitchatPalette.current
Column(modifier = modifier) {
if (geohashPeople.isEmpty()) {
SheetSectionLabel(text = stringResource(R.string.section_on_location))
// Empty state - matches iOS "nobody around..."
Text(
text = stringResource(R.string.nobody_around),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = palette.textTertiary,
modifier = Modifier.padding(
horizontal = SheetHorizontalPadding + 14.dp,
vertical = 12.dp
)
)
} else {
// Get current geohash identity for "me" detection
@ -129,12 +111,20 @@ fun GeohashPeopleList(
counts
}
val firstID = orderedPeople.firstOrNull()?.id
orderedPeople.forEach { person ->
// Split by presence: people physically in the geohash versus people who teleported
// in. Mixing them hid the fact that a "nearby" channel can contain remote users.
fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) {
isTeleported
} else {
viewModel.isPersonTeleported(person.id)
}
val (teleportedPeople, localPeople) = orderedPeople.partition { personTeleported(it) }
@Composable
fun personRow(person: GeoPerson) {
GeohashPersonItem(
person = person,
isFirst = person.id == firstID,
isMe = myHex != null && person.id == myHex,
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id),
@ -153,6 +143,16 @@ fun GeohashPeopleList(
}
)
}
if (localPeople.isNotEmpty()) {
SheetSectionLabel(text = stringResource(R.string.section_on_location))
localPeople.forEach { personRow(it) }
}
if (teleportedPeople.isNotEmpty()) {
SheetSectionLabel(text = stringResource(R.string.section_teleported_in))
teleportedPeople.forEach { personRow(it) }
}
}
}
}
@ -160,7 +160,6 @@ fun GeohashPeopleList(
@Composable
private fun GeohashPersonItem(
person: GeoPerson,
isFirst: Boolean,
isMe: Boolean,
hasUnreadDM: Boolean,
isTeleported: Boolean,
@ -171,12 +170,21 @@ private fun GeohashPersonItem(
showHashSuffix: Boolean,
onTap: () -> Unit
) {
val palette = LocalBitchatPalette.current
Surface(
onClick = onTap,
color = palette.surface,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onTap() }
.padding(horizontal = 24.dp, vertical = 4.dp)
.padding(top = if (isFirst) 10.dp else 0.dp),
.heightIn(min = 44.dp)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Icon logic matching iOS exactly
@ -185,16 +193,16 @@ private fun GeohashPersonItem(
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(12.dp),
tint = Color(0xFFFF9500) // iOS orange
modifier = Modifier.size(16.dp),
tint = palette.accentOrange
)
} else {
// Face icon with teleportation state
val (iconName, iconColor) = when {
isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me
isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others
isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me
else -> "face.smiling" to colorScheme.onSurface // Regular color for others
isMe && isMyTeleported -> "face.dashed" to palette.accentOrange
isTeleported -> "face.dashed" to palette.textSecondary
isMe -> "face.smiling" to palette.accentOrange
else -> "face.smiling" to palette.textSecondary
}
// Use appropriate Material icon (closest match to iOS SF Symbols)
@ -206,7 +214,7 @@ private fun GeohashPersonItem(
Icon(
imageVector = icon,
contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
modifier = Modifier.size(12.dp),
modifier = Modifier.size(16.dp),
tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent
)
}
@ -219,9 +227,8 @@ private fun GeohashPersonItem(
val suffix = if (showHashSuffix) suffixRaw else ""
// Get consistent peer color (matches iOS color assignment exactly)
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark)
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark)
val baseColor = if (isMe) palette.accentOrange else assignedColor
Row(
modifier = Modifier.weight(1f),
@ -230,42 +237,36 @@ private fun GeohashPersonItem(
// Base name with peer-specific color
Text(
text = baseName,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
),
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Suffix (collision-resistant #abcd) in lighter shade
if (suffix.isNotEmpty()) {
Text(
text = suffix,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = baseColor.copy(alpha = 0.6f)
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor.copy(alpha = SUFFIX_ALPHA)
)
}
// "You" indicator for current user
if (isMe) {
Text(
text = stringResource(R.string.you_suffix),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = baseColor
)
}
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}

View File

@ -2,6 +2,9 @@ package com.bitchat.android.ui
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@ -15,6 +18,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
@ -38,6 +42,8 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.withStyle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.features.voice.normalizeAmplitudeSample
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
@ -53,7 +59,10 @@ import com.bitchat.android.ui.media.FilePickerButton
* VisualTransformation that styles slash commands with background and color
* while preserving cursor positioning and click handling
*/
class SlashCommandVisualTransformation : VisualTransformation {
class SlashCommandVisualTransformation(
private val commandColor: Color,
private val commandBackground: Color,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)")
val annotatedString = buildAnnotatedString {
@ -68,10 +77,10 @@ class SlashCommandVisualTransformation : VisualTransformation {
// Add the styled slash command
withStyle(
style = SpanStyle(
color = Color(0xFF00FF7F), // Bright green
color = commandColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
background = Color(0xFF2D2D2D) // Dark gray background
background = commandBackground
)
) {
append(match.value)
@ -97,7 +106,10 @@ class SlashCommandVisualTransformation : VisualTransformation {
* VisualTransformation that styles mentions with background and color
* while preserving cursor positioning and click handling
*/
class MentionVisualTransformation : VisualTransformation {
class MentionVisualTransformation(
private val mentionColor: Color,
private val mentionBackground: Color,
) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val mentionRegex = Regex("@([a-zA-Z0-9_]+)")
val annotatedString = buildAnnotatedString {
@ -112,9 +124,12 @@ class MentionVisualTransformation : VisualTransformation {
// Add the styled mention
withStyle(
style = SpanStyle(
color = Color(0xFFFF9500), // Orange
color = mentionColor,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
// Mirrors the mention chip used in rendered messages, so what you type
// looks like what everyone will see.
background = mentionBackground
)
) {
append(match.value)
@ -159,6 +174,24 @@ class CombinedVisualTransformation(private val transformations: List<VisualTrans
/** Minimum height of the composer pill: a single line of 15.sp text plus 12.dp of padding. */
private val ComposerMinHeight = 44.dp
/**
* Composer corner radius.
*
* Fixed rather than "50%": at [ComposerMinHeight] this yields a true capsule, and when the field
* grows to multiple lines it stays a generously rounded rectangle instead of degenerating into
* the stadium shape a percentage radius would produce.
*/
private val ComposerShape = RoundedCornerShape(22.dp)
/** Diameter of the circular send affordance nested inside the pill. */
private val SendButtonSize = 32.dp
/** Tap target for the media affordances sitting outside the pill. */
private val MediaButtonSize = 40.dp
@Composable
fun MessageInput(
value: TextFieldValue,
@ -174,6 +207,7 @@ fun MessageInput(
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val isFocused = remember { mutableStateOf(false) }
val hasText = value.text.isNotBlank() // Check if there's text for send button state
val keyboard = LocalSoftwareKeyboardController.current
@ -182,82 +216,121 @@ fun MessageInput(
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Text input with placeholder OR visualizer when recording
Box(
modifier = Modifier.weight(1f)
) {
// Always keep the text field mounted to retain focus and avoid IME collapse
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
),
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (hasText) onSend() // Only send if there's text
}),
visualTransformation = CombinedVisualTransformation(
listOf(SlashCommandVisualTransformation(), MentionVisualTransformation())
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
isFocused.value = focusState.isFocused
}
)
// Recording turns the pill's outline red; a cross-fade keeps that from flashing.
val borderColor by animateColorAsState(
targetValue = when {
isRecording -> palette.accentRed.copy(alpha = 0.6f)
isFocused.value -> palette.outline
else -> palette.outlineVariant
},
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "composerBorder"
)
// Show placeholder when there's no text and not recording
if (value.text.isEmpty() && !isRecording) {
Text(
text = stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
Row(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
// Bottom-aligned so that when the field grows to several lines the send button and the
// media buttons stay pinned next to the last line, as in the design's long-text state.
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
// MARK: - The pill. Holds the field and the send button as one visual object.
Row(
modifier = Modifier
.weight(1f)
.heightIn(min = ComposerMinHeight)
.background(palette.surfaceVariant, ComposerShape)
.border(1.dp, borderColor, ComposerShape),
verticalAlignment = Alignment.Bottom
) {
Box(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp)
) {
// Always keep the text field mounted to retain focus and avoid IME collapse
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey
modifier = Modifier.fillMaxWidth()
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (hasText) onSend() // Only send if there's text
}),
// Cap the growth so a pasted wall of text cannot swallow the message list.
maxLines = 6,
visualTransformation = remember(palette) {
CombinedVisualTransformation(
listOf(
SlashCommandVisualTransformation(
commandColor = palette.accentGreen,
commandBackground = palette.accentGreen.copy(alpha = 0.14f),
),
MentionVisualTransformation(
mentionColor = palette.accentOrange,
mentionBackground = palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA),
),
)
)
},
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
isFocused.value = focusState.isFocused
}
)
}
// Overlay the real-time scrolling waveform while recording
if (isRecording) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
RealtimeScrollingWaveform(
modifier = Modifier.weight(1f).height(32.dp),
amplitudeNorm = normalizeAmplitudeSample(amplitude)
)
Spacer(Modifier.width(20.dp))
val secs = (elapsedMs / 1000).toInt()
val mm = secs / 60
val ss = secs % 60
val maxSecs = 10 // 10 second max recording time
val maxMm = maxSecs / 60
val maxSs = maxSecs % 60
// Show placeholder when there's no text and not recording
if (value.text.isEmpty() && !isRecording) {
Text(
text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs),
fontFamily = FontFamily.Monospace,
color = colorScheme.primary,
fontSize = (BASE_FONT_SIZE - 4).sp
text = stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = palette.textTertiary,
modifier = Modifier.fillMaxWidth()
)
}
}
}
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
// Voice and image buttons when no text (only visible in Mesh chat)
if (value.text.isEmpty() && showMediaButtons) {
// Hold-to-record microphone
val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f)
// Overlay the real-time scrolling waveform while recording
if (isRecording) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
RealtimeScrollingWaveform(
modifier = Modifier.weight(1f).height(24.dp),
amplitudeNorm = normalizeAmplitudeSample(amplitude)
)
Spacer(Modifier.width(12.dp))
val secs = (elapsedMs / 1000).toInt()
val mm = secs / 60
val ss = secs % 60
val maxSecs = 10 // 10 second max recording time
val maxMm = maxSecs / 60
val maxSs = maxSecs % 60
Text(
text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs),
fontFamily = FontFamily.Monospace,
color = palette.accentRed,
fontSize = (BASE_FONT_SIZE - 4).sp
)
}
}
}
SendButton(
hasText = hasText,
isAccented = selectedPrivatePeer != null || currentChannel != null,
onSend = onSend,
modifier = Modifier.padding(end = 6.dp, bottom = 6.dp)
)
}
// MARK: - Media affordances, outside the pill, only while the field is empty.
if (value.text.isEmpty() && showMediaButtons) {
// Ensure latest values are used when finishing recording
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
val latestChannel = rememberUpdatedState(currentChannel)
@ -265,26 +338,15 @@ fun MessageInput(
// Image button (image picker) - hide during recording
if (!isRecording) {
// Revert to original separate buttons: round File button (left) and the old Image plus button (right)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
// DISABLE FILE PICKER
//FilePickerButton(
// onFileReady = { path ->
// onSendFileNote(latestSelectedPeer.value, latestChannel.value, path)
// }
//)
ImagePickerButton(
onImageReady = { outPath ->
onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath)
}
)
}
ImagePickerButton(
onImageReady = { outPath ->
onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath)
}
)
}
Spacer(Modifier.width(1.dp))
VoiceRecordButton(
backgroundColor = bg,
backgroundColor = colorScheme.primary,
onStart = {
isRecording = true
elapsedMs = 0L
@ -313,72 +375,81 @@ fun MessageInput(
)
}
)
} else {
// Send button with enabled/disabled state
IconButton(
onClick = { if (hasText) onSend() }, // Only execute if there's text
enabled = hasText, // Enable only when there's text
modifier = Modifier.size(32.dp)
) {
// Update send button to match input field colors
Box(
modifier = Modifier
.size(30.dp)
.background(
color = if (!hasText) {
// Disabled state - muted grey
colorScheme.onSurface.copy(alpha = 0.3f)
} else if (selectedPrivatePeer != null || currentChannel != null) {
// Orange for both private messages and channels when enabled
Color(0xFFFF9500).copy(alpha = 0.75f)
} else if (colorScheme.background == Color.Black) {
Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme
} else {
Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(id = R.string.send_message),
modifier = Modifier.size(20.dp),
tint = if (!hasText) {
// Disabled state - muted grey icon
colorScheme.onSurface.copy(alpha = 0.5f)
} else if (selectedPrivatePeer != null || currentChannel != null) {
// Black arrow on orange for both private and channel modes
Color.Black
} else if (colorScheme.background == Color.Black) {
Color.Black // Black arrow on bright green in dark theme
} else {
Color.White // White arrow on dark green in light theme
}
)
}
}
}
}
// Auto-stop handled inside VoiceRecordButton
}
/**
* Circular send affordance nested in the bottom-right of the composer pill.
*
* Goes from a flat grey disc to a solid accent the moment there is something to send, which is
* the clearest possible signal that the return key will do something.
*/
@Composable
private fun SendButton(
hasText: Boolean,
isAccented: Boolean,
onSend: () -> Unit,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val targetBackground = when {
!hasText -> palette.outline
isAccented -> palette.accentOrange
else -> colorScheme.primary
}
val targetTint = when {
!hasText -> palette.textTertiary
// Both accents are bright enough that black is the only legible arrow colour.
else -> Color.Black
}
val background by animateColorAsState(
targetValue = targetBackground,
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
label = "sendButtonBackground"
)
val tint by animateColorAsState(
targetValue = targetTint,
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
label = "sendButtonTint"
)
Box(
modifier = modifier
.size(SendButtonSize)
.background(background, CircleShape)
.clip(CircleShape)
.clickable(enabled = hasText) { onSend() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(id = R.string.send_message),
modifier = Modifier.size(18.dp),
tint = tint
)
}
}
@Composable
fun CommandSuggestionsBox(
suggestions: List<CommandSuggestion>,
onSuggestionClick: (CommandSuggestion) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.background(colorScheme.surface)
.border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
.padding(vertical = 8.dp)
.background(palette.surface)
.border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp))
.padding(vertical = 6.dp)
) {
suggestions.forEach { suggestion: CommandSuggestion ->
CommandSuggestionItem(
@ -395,13 +466,14 @@ fun CommandSuggestionItem(
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color.Gray.copy(alpha = 0.1f)),
// Roomier rows: at 3.dp vertical these were below a comfortable tap height.
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
@ -419,7 +491,7 @@ fun CommandSuggestionItem(
fontWeight = FontWeight.Medium
),
color = colorScheme.primary,
fontSize = (BASE_FONT_SIZE - 4).sp
fontSize = (BASE_FONT_SIZE - 2).sp
)
// Show syntax if any
@ -429,8 +501,8 @@ fun CommandSuggestionItem(
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.8f),
fontSize = (BASE_FONT_SIZE - 5).sp
color = palette.textSecondary,
fontSize = (BASE_FONT_SIZE - 4).sp
)
}
@ -440,8 +512,8 @@ fun CommandSuggestionItem(
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 5).sp,
color = palette.textTertiary,
fontSize = (BASE_FONT_SIZE - 4).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -454,13 +526,13 @@ fun MentionSuggestionsBox(
onSuggestionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Column(
modifier = modifier
.background(colorScheme.surface)
.border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
.padding(vertical = 8.dp)
.background(palette.surface)
.border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp))
.padding(vertical = 6.dp)
) {
suggestions.forEach { suggestion: String ->
MentionSuggestionItem(
@ -476,14 +548,13 @@ fun MentionSuggestionItem(
suggestion: String,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color.Gray.copy(alpha = 0.1f)),
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
@ -492,19 +563,19 @@ fun MentionSuggestionItem(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold
),
color = Color(0xFFFF9500), // Orange like mentions
fontSize = (BASE_FONT_SIZE - 4).sp
color = palette.accentOrange,
fontSize = (BASE_FONT_SIZE - 2).sp
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.mention),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 5).sp
color = palette.textTertiary,
fontSize = (BASE_FONT_SIZE - 4).sp
)
}
}

View File

@ -3,11 +3,17 @@ package com.bitchat.android.ui
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
@ -15,6 +21,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -41,6 +48,11 @@ import com.bitchat.android.R
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
/**
* Location Channels Sheet for selecting geohash-based location channels
@ -109,11 +121,10 @@ fun LocationChannelsSheet(
}
}
// iOS system colors (matches iOS exactly)
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
val standardBlue = Color(0xFF007AFF) // iOS blue
val palette = LocalBitchatPalette.current
val standardGreen = palette.accentGreen
val standardBlue = palette.accentBlue
if (isPresented) {
BitchatBottomSheet(
@ -129,14 +140,28 @@ fun LocationChannelsSheet(
) {
// Header Section
item(key = "header") {
Text(
text = stringResource(R.string.location_channels_desc),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
)
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding + 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
SheetHeaderBadge(icon = Icons.Outlined.Public)
Text(
text = stringResource(R.string.location_channels_heading),
fontSize = 20.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = colorScheme.primary
)
Text(
text = stringResource(R.string.location_channels_desc),
fontSize = 12.sp,
lineHeight = 17.sp,
fontFamily = FontFamily.Monospace,
color = palette.textSecondary
)
}
}
// Permission controls if services enabled
@ -156,7 +181,7 @@ fun LocationChannelsSheet(
text = stringResource(R.string.location_permission_denied),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = Color.Red.copy(alpha = 0.8f)
color = palette.accentRed
)
TextButton(
onClick = {
@ -208,6 +233,11 @@ fun LocationChannelsSheet(
// iOS: let nearby = manager.availableChannels.filter { $0.level != .building }
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
if (nearbyChannels.isNotEmpty()) {
item(key = "nearby_header") {
SheetSectionLabel(text = stringResource(R.string.section_nearby))
}
}
items(nearbyChannels) { channel ->
val coverage = coverageString(channel.geohash.length)
val nameBase = locationNames[channel.level]
@ -224,13 +254,14 @@ fun LocationChannelsSheet(
titleColor = standardGreen,
titleBold = highlight,
trailingContent = {
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark),
tint = if (isBookmarked) palette.accentGreen else palette.textSecondary,
modifier = Modifier.size(18.dp)
)
}
},
onClick = {
// Selecting a suggested nearby channel is not a teleport
@ -252,12 +283,13 @@ fun LocationChannelsSheet(
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
color = palette.textSecondary
)
Text(
text = stringResource(R.string.finding_nearby_channels),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
fontFamily = FontFamily.Monospace,
color = palette.textSecondary
)
}
}
@ -266,16 +298,7 @@ fun LocationChannelsSheet(
// Bookmarked geohashes
if (bookmarks.isNotEmpty()) {
item(key = "bookmarked_header") {
Text(
text = stringResource(R.string.bookmarked),
style = MaterialTheme.typography.labelLarge,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
SheetSectionLabel(text = stringResource(R.string.section_bookmarked))
}
items(bookmarks) { gh ->
val level = levelForLength(gh.length)
@ -298,7 +321,8 @@ fun LocationChannelsSheet(
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = stringResource(R.string.cd_remove_bookmark),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
tint = palette.accentGreen,
modifier = Modifier.size(18.dp)
)
}
},
@ -321,16 +345,17 @@ fun LocationChannelsSheet(
// Custom geohash teleport (iOS-style inline form)
item(key = "custom_geohash") {
Surface(
color = Color.Transparent,
shape = MaterialTheme.shapes.medium,
color = palette.surface,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
.heightIn(min = 44.dp)
.padding(start = 14.dp, end = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
@ -338,7 +363,7 @@ fun LocationChannelsSheet(
text = stringResource(R.string.hash_symbol),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
color = palette.textTertiary
)
BasicTextField(
@ -358,8 +383,9 @@ fun LocationChannelsSheet(
textStyle = androidx.compose.ui.text.TextStyle(
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
color = colorScheme.primary
),
cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary),
modifier = Modifier
.weight(1f)
.onFocusChanged { focusState ->
@ -381,7 +407,7 @@ fun LocationChannelsSheet(
text = stringResource(R.string.geohash_placeholder),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
color = palette.textTertiary
)
}
innerTextField()
@ -405,14 +431,17 @@ fun LocationChannelsSheet(
Icon(
imageVector = Icons.Filled.Map,
contentDescription = stringResource(R.string.cd_open_map),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
tint = palette.textSecondary,
modifier = Modifier.size(18.dp)
)
}
val isValid = validateGeohash(normalized)
// iOS-style teleport button
Button(
// Compact teleport pill. Dims rather than disappearing when the
// geohash is incomplete so its position stays predictable.
val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary
Surface(
onClick = {
if (isValid) {
val level = levelForLength(normalized.length)
@ -426,25 +455,29 @@ fun LocationChannelsSheet(
}
},
enabled = isValid,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
shape = RoundedCornerShape(8.dp),
color = palette.surfaceVariant
) {
Row(
modifier = Modifier
.height(32.dp)
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.teleport),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace
)
Icon(
imageVector = Icons.Filled.PinDrop,
contentDescription = stringResource(R.string.cd_teleport),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurface
modifier = Modifier.size(12.dp),
tint = teleportColor
)
Text(
text = stringResource(R.string.teleport).uppercase(),
fontSize = 11.sp,
letterSpacing = 0.8.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace,
color = teleportColor
)
}
}
@ -459,51 +492,103 @@ fun LocationChannelsSheet(
text = customError!!,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = Color.Red,
color = palette.accentRed,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 4.dp)
)
}
}
// Tor routing. Mirrors the toggle in About: this is where users actually
// think about IP exposure, because location channels are the feature that
// leaks it.
item(key = "tor_routing") {
val torProvider = remember { ArtiTorManager.getInstance() }
val torAvailable = remember { torProvider.isTorAvailable() }
var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) }
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
.padding(top = 16.dp),
color = palette.surface,
shape = RoundedCornerShape(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = stringResource(R.string.location_tor_routing_title),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = if (torAvailable) palette.textPrimary else palette.textTertiary
)
BitchatBadge(text = stringResource(R.string.badge_recommended))
}
Text(
text = stringResource(R.string.location_tor_routing_desc),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = palette.textSecondary
)
}
Switch(
checked = torMode == TorMode.ON,
onCheckedChange = { enabled ->
if (torAvailable) {
torMode = if (enabled) TorMode.ON else TorMode.OFF
TorPreferenceManager.set(context, torMode)
}
},
enabled = torAvailable,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = palette.accentGreen,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = palette.surfaceVariant
)
)
}
}
}
// Location services toggle button
item(key = "location_toggle") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp)
) {
Button(
onClick = {
if (locationServicesEnabled) {
locationManager.disableLocationServices()
} else {
locationManager.enableLocationServices()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = if (locationServicesEnabled) {
Color.Red.copy(alpha = 0.08f)
} else {
standardGreen.copy(alpha = 0.12f)
},
contentColor = if (locationServicesEnabled) {
Color(0xFFBF1A1A)
} else {
standardGreen
}
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
}
SheetDestructiveButton(
text = if (locationServicesEnabled) {
stringResource(R.string.remove_location_access)
} else {
stringResource(R.string.enable_location_services)
},
isDestructive = locationServicesEnabled,
onClick = {
if (locationServicesEnabled) {
locationManager.disableLocationServices()
} else {
locationManager.enableLocationServices()
}
},
modifier = Modifier.padding(
start = SheetHorizontalPadding,
end = SheetHorizontalPadding,
top = 16.dp
)
)
}
}
@ -561,26 +646,41 @@ private fun ChannelRow(
trailingContent: (@Composable (() -> Unit))? = null,
onClick: () -> Unit
) {
// iOS-style list row (plain button, no card background)
val palette = LocalBitchatPalette.current
// Selected rows step up to surfaceVariant rather than showing a trailing checkmark: with a
// leading status dot the fill alone communicates state and keeps the row's right edge free
// for the bookmark control.
val containerColor by animateColorAsState(
targetValue = if (isSelected) palette.surfaceVariant else palette.surface,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "channelRowContainer"
)
Surface(
onClick = onClick,
color = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
Color.Transparent
},
shape = MaterialTheme.shapes.medium,
color = containerColor,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (isSelected) {
Box(
modifier = Modifier
.padding(end = 10.dp)
.size(6.dp)
.background(palette.accentGreen, CircleShape)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
@ -588,13 +688,16 @@ private fun ChannelRow(
// Split title to handle count part with smaller font (iOS style)
val (baseTitle, countSuffix) = splitTitleAndCount(title)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = baseTitle,
fontSize = BASE_FONT_SIZE.sp,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal,
color = titleColor ?: MaterialTheme.colorScheme.onSurface
fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium,
color = titleColor ?: palette.textPrimary
)
countSuffix?.let { count ->
@ -602,32 +705,21 @@ private fun ChannelRow(
text = count,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
color = palette.textSecondary
)
}
}
Text(
text = subtitle,
fontSize = 12.sp,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
color = palette.textSecondary
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (isSelected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.cd_selected),
tint = Color(0xFF32D74B), // iOS green for checkmark
modifier = Modifier.size(20.dp)
)
}
if (trailingContent != null) {
trailingContent()
}
if (trailingContent != null) {
trailingContent()
}
}
}

View File

@ -19,6 +19,7 @@ import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
import com.bitchat.android.ui.theme.LocalBitchatPalette
/**
* Location Notes button component for MainHeader
@ -54,13 +55,13 @@ fun LocationNotesButton(
val hasNotes = notesCount > 0
IconButton(
onClick = onClick,
modifier = modifier.size(24.dp)
modifier = modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
contentDescription = stringResource(R.string.cd_location_notes),
modifier = Modifier.size(16.dp),
tint = if (hasNotes) colorScheme.primary else Color.Gray
modifier = Modifier.size(18.dp),
tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary
)
}
}

View File

@ -81,6 +81,7 @@ fun MessageWithMatrixAnimation(
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true,
) {
AnimatedMessageDisplay(
message = message,
@ -91,6 +92,7 @@ fun MessageWithMatrixAnimation(
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
showSender = showSender,
)
}
@ -107,6 +109,7 @@ private fun AnimatedMessageDisplay(
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true,
) {
var animatedContent by remember(message.id, message.content) {
mutableStateOf(message.content)
@ -174,6 +177,7 @@ private fun AnimatedMessageDisplay(
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
showSender = showSender,
bodyContent = animatedContent,
)
}

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
@ -36,6 +37,7 @@ import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.services.ContactDirectory
@ -68,6 +70,8 @@ fun MeshPeerListSheet(
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val geohashPeopleCount = geohashPeople.size
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
@ -100,19 +104,33 @@ fun MeshPeerListSheet(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
) {
// Header: badge + live participant count
item(key = "people_header") {
val peopleCount = when (selectedLocationChannel) {
is ChannelID.Location -> geohashPeopleCount
else -> connectedPeers.count { it != viewModel.myPeerID }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding + 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
SheetHeaderBadge(icon = Icons.Filled.Groups)
Text(
text = stringResource(R.string.people_count_title, peopleCount),
fontSize = 20.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = colorScheme.primary
)
}
}
// Channels section
if (joinedChannels.isNotEmpty()) {
item(key = "channels_header") {
Text(
text = stringResource(id = R.string.channels).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
SheetSectionLabel(text = stringResource(R.string.section_channels))
}
items(
@ -222,22 +240,21 @@ private fun ChannelRow(
onChannelClick: () -> Unit,
onLeaveChannel: () -> Unit,
) {
val palette = LocalBitchatPalette.current
Surface(
onClick = onChannelClick,
color = if (isSelected) {
colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
Color.Transparent
},
shape = MaterialTheme.shapes.medium,
color = if (isSelected) palette.surfaceVariant else palette.surface,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.heightIn(min = 44.dp)
.padding(start = 14.dp, end = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@ -253,15 +270,13 @@ private fun ChannelRow(
colorScheme = colorScheme
)
}
Text(
text = channel,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = if (isSelected) colorScheme.primary else palette.textPrimary,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium
)
}
@ -298,29 +313,20 @@ fun PeopleSection(
SecureIdentityStateManager(context.applicationContext)
}
val palette = LocalBitchatPalette.current
Column(modifier = modifier) {
Text(
text = stringResource(id = R.string.people).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
SheetSectionLabel(text = stringResource(R.string.section_mesh))
if (connectedPeers.isEmpty()) {
Text(
text = stringResource(id = R.string.no_one_connected),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
),
color = colorScheme.onSurface.copy(alpha = 0.5f),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = palette.textTertiary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 12.dp)
.padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 12.dp)
)
}
@ -547,26 +553,23 @@ private fun PeerItem(
val isMe = displayName == "You" || peerID == currentNickname
// Get consistent peer color (iOS-compatible)
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
val palette = LocalBitchatPalette.current
val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark)
val baseColor = if (isMe) palette.accentOrange else assignedColor
Surface(
onClick = onItemClick,
color = if (isSelected) {
colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
Color.Transparent
},
shape = MaterialTheme.shapes.medium,
color = if (isSelected) palette.surfaceVariant else palette.surface,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.heightIn(min = 44.dp)
.padding(start = 14.dp, end = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@ -622,11 +625,9 @@ private fun PeerItem(
// Base name with peer-specific color
Text(
text = baseName,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
),
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@ -636,11 +637,10 @@ private fun PeerItem(
if (suffix.isNotEmpty()) {
Text(
text = suffix,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
),
color = baseColor.copy(alpha = 0.6f)
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor.copy(alpha = SUFFIX_ALPHA)
)
}

View File

@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
@ -41,6 +41,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
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.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
@ -50,6 +51,10 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
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.LocalBitchatPalette
import com.bitchat.android.ui.theme.MessageBodyTextStyle
import com.bitchat.android.ui.theme.MessageSenderTextStyle
import java.text.SimpleDateFormat
import java.util.Locale
@ -116,25 +121,42 @@ fun MessagesList(
LazyColumn(
state = listState,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
// Wider side gutters than the old 12.dp: the redesign trades a little line length for
// a much calmer edge, and long monospace lines were running into the screen bezel.
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
// Spacing is owned by each item so that a continuation of the same author can sit
// tighter than the start of a new speaker's run.
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier,
reverseLayout = true
) {
items(
items = messages.asReversed(),
key = { it.id }
) { message ->
MessageItem(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick
)
val reversed = messages.asReversed()
itemsIndexed(
items = reversed,
key = { _, message -> message.id }
) { reversedIndex, message ->
// reverseLayout renders index 0 at the bottom, so the chronological predecessor of
// this row lives at a *higher* original index offset. Resolve against the original
// list rather than the reversed view to keep the grouping logic readable.
val originalIndex = messages.lastIndex - reversedIndex
val previous = messages.getOrNull(originalIndex - 1)
val isGrouped = MessageGrouping.shouldGroup(previous, message)
MessageItem(
message = message,
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
showSender = !isGrouped,
topSpacing = MessageGrouping.topSpacingFor(
isGrouped = isGrouped,
isFirstInList = originalIndex == 0
),
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick
)
}
}
}
@ -146,6 +168,8 @@ fun MessageItem(
currentUserNickname: String,
meshService: MeshService,
messages: List<BitchatMessage> = emptyList(),
showSender: Boolean = true,
topSpacing: Dp = 0.dp,
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
@ -155,7 +179,9 @@ fun MessageItem(
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
Column(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(top = topSpacing),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Box(modifier = Modifier.fillMaxWidth()) {
@ -174,6 +200,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -211,12 +238,15 @@ fun MessageItem(
meshService: MeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
showSender: Boolean,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
// Image special rendering
if (message.type == BitchatMessageType.Image) {
com.bitchat.android.ui.media.ImageMessageItem(
@ -226,6 +256,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -243,6 +274,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@ -269,8 +301,9 @@ fun MessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
palette = palette,
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@ -287,7 +320,7 @@ fun MessageItem(
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface,
color = palette.textPrimary,
)
// Try to load the file packet from the path
@ -363,19 +396,20 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier
)
} else if (message.sender == "system") {
// Keep system messages on the compact legacy line.
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
// Background narration: `// Tor started. Routing all chats…`
val annotatedText = remember(message, palette) {
formatSystemMessage(
message = message,
palette = palette,
timeFormatter = timeFormatter
)
}
val haptic = LocalHapticFeedback.current
Text(
@ -391,8 +425,10 @@ fun MessageItem(
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
style = MessageBodyTextStyle.copy(
fontSize = (BASE_FONT_SIZE - 2).sp,
lineHeight = (BASE_FONT_SIZE + 3).sp,
color = palette.textSecondary
)
)
} else {
@ -402,6 +438,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
@ -419,32 +456,30 @@ internal fun TextMessageLayout(
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
showSender: Boolean = true,
bodyContent: String = message.content,
) {
val palette = LocalBitchatPalette.current
val myPeerId = meshService.myPeerID
val displayMessage = remember(message, bodyContent) {
if (bodyContent == message.content) message else message.copy(content = bodyContent)
}
val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) {
val senderText = remember(message, currentUserNickname, myPeerId, palette) {
formatTextMessageSender(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
palette = palette,
)
}
val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) {
formatTextMessageMetadata(
message = message,
timeFormatter = timeFormatter,
)
}
val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) {
// 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.
val bodyText = remember(displayMessage, currentUserNickname, palette, timeFormatter) {
formatTextMessageBody(
message = displayMessage,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
palette = palette,
timeFormatter = timeFormatter,
)
}
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
@ -457,12 +492,9 @@ internal fun TextMessageLayout(
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (showSender) {
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
@ -476,18 +508,11 @@ internal fun TextMessageLayout(
}
},
onLongPress = handleLongPress,
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth(),
fontFamily = FontFamily.Monospace,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
AnnotatedClickableText(
text = metadataText,
annotationTags = emptyList(),
onAnnotationClick = { _, _ -> false },
onLongPress = handleLongPress,
fontFamily = FontFamily.Monospace,
softWrap = false,
style = MessageSenderTextStyle,
)
}
@ -515,7 +540,7 @@ internal fun TextMessageLayout(
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface),
style = MessageBodyTextStyle.copy(color = palette.textPrimary),
)
}
}

View File

@ -0,0 +1,79 @@
package com.bitchat.android.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.bitchat.android.model.BitchatMessage
/**
* Consecutive-message grouping for the chat surface.
*
* The redesign suppresses the `@sender` label on runs of messages from the same author so the
* eye only has to register a name when the speaker actually changes. Grouped messages sit
* closer together; a new group gets extra breathing room above it.
*/
object MessageGrouping {
/** Space above the first message of a group (i.e. one that renders its sender label). */
val NEW_GROUP_SPACING: Dp = 10.dp
/** Space above a continuation message inside an existing group. */
val GROUPED_SPACING: Dp = 4.dp
/** Gap between the sender label and the first line of the body. */
val SENDER_TO_BODY_SPACING: Dp = 2.dp
/**
* Messages further apart than this always start a new group, even from the same sender:
* a reply an hour later is a new thought, not a continuation.
*/
const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L
private const val SYSTEM_SENDER = "system"
/**
* Whether [current] should be rendered as a continuation of [previous], hiding its sender
* label.
*
* [previous] is the message immediately *before* [current] in chronological order. Note the
* message list renders with `reverseLayout = true`, so callers must be careful to resolve
* the chronological predecessor rather than the visually preceding item.
*/
fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean {
if (previous == null) return false
// System/action lines never participate in grouping in either direction: they are
// narration, and folding a real message into them would attribute it to "system".
if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false
// Never group across the public/private boundary or between different channels.
if (previous.isPrivate != current.isPrivate) return false
if (previous.channel != current.channel) return false
if (!isSameSender(previous, current)) return false
val elapsed = current.timestamp.time - previous.timestamp.time
return elapsed in 0..GROUPING_WINDOW_MS
}
/**
* Identity comparison. Peer IDs are authoritative when both messages carry one, because two
* different peers can share a nickname. Falls back to the display name (which includes the
* `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages.
*/
private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean {
val previousPeerID = previous.senderPeerID
val currentPeerID = current.senderPeerID
return if (previousPeerID != null && currentPeerID != null) {
previousPeerID.equals(currentPeerID, ignoreCase = true)
} else {
previous.sender == current.sender
}
}
/** Top padding for a message given whether it continues the previous author's run. */
fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when {
isFirstInList -> 0.dp
isGrouped -> GROUPED_SPACING
else -> NEW_GROUP_SPACING
}
}

View File

@ -56,7 +56,7 @@ fun VoiceRecordButton(
Box(
modifier = modifier
.size(32.dp)
.size(36.dp)
.background(backgroundColor, CircleShape)
.pointerInput(Unit) {
detectTapGestures(

View File

@ -21,6 +21,7 @@ import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
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 java.text.SimpleDateFormat
@Composable
@ -33,8 +34,10 @@ fun AudioMessageItem(
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
showSender: Boolean = true
) {
val palette = LocalBitchatPalette.current
val path = message.content.trim()
// Derive sending progress if applicable
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
@ -51,8 +54,9 @@ fun AudioMessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
palette = palette,
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@ -69,7 +73,7 @@ fun AudioMessageItem(
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface,
color = palette.textPrimary,
)
Row(verticalAlignment = Alignment.CenterVertically) {

View File

@ -27,6 +27,7 @@ import com.bitchat.android.model.BitchatMessage
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 java.text.SimpleDateFormat
@Composable
@ -41,16 +42,19 @@ fun ImageMessageItem(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
showSender: Boolean = true
) {
val palette = LocalBitchatPalette.current
val path = message.content.trim()
Column(modifier = modifier.fillMaxWidth()) {
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
palette = palette,
timeFormatter = timeFormatter,
includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@ -67,7 +71,7 @@ fun ImageMessageItem(
},
onLongPress = { onMessageLongPress?.invoke(message) },
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface,
color = palette.textPrimary,
)
val context = LocalContext.current

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.Photo
@ -17,6 +18,7 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -24,6 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.bitchat.android.features.media.ImageUtils
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.io.File
@OptIn(ExperimentalFoundationApi::class)
@ -88,7 +91,9 @@ fun ImagePickerButton(
Box(
modifier = modifier
.size(32.dp)
// 40.dp to match the header's tap targets and the composer's send button.
.size(40.dp)
.clip(CircleShape)
.combinedClickable(
onClick = { imagePicker.launch("image/*") },
onLongClick = {
@ -104,7 +109,7 @@ fun ImagePickerButton(
Icon(
imageVector = Icons.Filled.PhotoCamera,
contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
tint = Color.Gray,
tint = LocalBitchatPalette.current.textSecondary,
modifier = Modifier.size(20.dp)
)
}

View File

@ -0,0 +1,106 @@
package com.bitchat.android.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* Semantic design tokens for the bitchat redesign.
*
* Material3's [androidx.compose.material3.ColorScheme] cannot express what this app needs:
* `onSurface` is the terminal green used for chrome, while message bodies must render in a
* neutral near-white. Rather than repurposing M3 slots (which would repaint ~40 unrelated
* files unpredictably), the redesign exposes an explicit palette through
* [LocalBitchatPalette].
*
* Consumers should prefer these tokens over ad-hoc hex literals so accents stay consistent
* and light/dark pairs are defined in exactly one place.
*/
@Immutable
data class BitchatPalette(
/** Authoritative dark-mode flag. Prefer this over summing background channels. */
val isDark: Boolean,
// MARK: - Surfaces
/** Screen, header and composer background. Slightly green-tinted near-black. */
val background: Color,
/** Sheets, cards and list rows: one step above [background]. */
val surface: Color,
/** Composer pill fill, chips, selected rows: one step above [surface]. */
val surfaceVariant: Color,
/** Borders, drawn at full opacity (no `.copy(alpha = ...)` needed). */
val outline: Color,
/** Hairline dividers. */
val outlineVariant: Color,
// MARK: - Text
/** Message bodies and row titles. Neutral, not green. */
val textPrimary: Color,
/** Subtitles, secondary metadata, system/action messages. */
val textSecondary: Color,
/** Timestamps, placeholders, section labels, disabled states. */
val textTertiary: Color,
// MARK: - Accents
/** Geohash channels, bookmark-on, connected/OK states. */
val accentGreen: Color,
/** Mesh channel, links, geohash references. */
val accentBlue: Color,
/** Self, mentions targeting you, unread DMs. */
val accentOrange: Color,
/** Errors, warning card, destructive actions. */
val accentRed: Color,
/** Nostr reachability. */
val accentPurple: Color,
)
val DarkBitchatPalette = BitchatPalette(
isDark = true,
background = Color(0xFF070D07),
surface = Color(0xFF0E150E),
surfaceVariant = Color(0xFF182118),
outline = Color(0xFF2A3A2A),
outlineVariant = Color(0xFF1C271C),
textPrimary = Color(0xFFE8EDE8),
textSecondary = Color(0xFF9AA69A),
textTertiary = Color(0xFF6B776B),
accentGreen = Color(0xFF32D74B),
accentBlue = Color(0xFF0A84FF),
accentOrange = Color(0xFFFF9F0A),
accentRed = Color(0xFFFF453A),
accentPurple = Color(0xFFBF5AF2),
)
val LightBitchatPalette = BitchatPalette(
isDark = false,
background = Color(0xFFFBFDFB),
surface = Color(0xFFF2F6F2),
surfaceVariant = Color(0xFFE7EDE7),
outline = Color(0xFFCBD6CB),
outlineVariant = Color(0xFFDEE6DE),
textPrimary = Color(0xFF131A13),
textSecondary = Color(0xFF4C574C),
textTertiary = Color(0xFF757F75),
accentGreen = Color(0xFF248A3D),
accentBlue = Color(0xFF007AFF),
accentOrange = Color(0xFFFF9500),
accentRed = Color(0xFFD70015),
accentPurple = Color(0xFFAF52DE),
)
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }
/**
* Motion tokens. The redesign leans on short, snappy transitions: long durations read as
* sluggish on a chat surface where the user is scanning quickly.
*/
object BitchatMotion {
/** Icon tints, text colors, small fills. */
const val QUICK_MS = 120
/** Tab indicators, pill growth, chip reveals. */
const val STANDARD_MS = 180
/** Sheet-level fades and scroll-driven top bars. */
const val EMPHASIZED_MS = 240
}

View File

@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -16,17 +17,23 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
// Colors that match the iOS bitchat theme
// Colors that match the iOS bitchat theme.
// Surface/outline/error slots are derived from BitchatPalette so that screens which have not
// yet migrated to the explicit palette still pick up the redesigned tinted surfaces.
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF39FF14), // Bright green (terminal-like)
onPrimary = Color.Black,
secondary = Color(0xFF2ECB10), // Darker green
onSecondary = Color.Black,
background = Color.Black,
onBackground = Color(0xFF39FF14), // Green on black
surface = Color(0xFF111111), // Very dark gray
background = DarkBitchatPalette.background,
onBackground = Color(0xFF39FF14), // Green on near-black
surface = DarkBitchatPalette.surface,
onSurface = Color(0xFF39FF14), // Green text
error = Color(0xFFFF5555), // Red for errors
surfaceVariant = DarkBitchatPalette.surfaceVariant,
onSurfaceVariant = DarkBitchatPalette.textSecondary,
outline = DarkBitchatPalette.outline,
outlineVariant = DarkBitchatPalette.outlineVariant,
error = DarkBitchatPalette.accentRed,
onError = Color.Black
)
@ -35,11 +42,15 @@ private val LightColorScheme = lightColorScheme(
onPrimary = Color.White,
secondary = Color(0xFF006600), // Even darker green
onSecondary = Color.White,
background = Color.White,
onBackground = Color(0xFF008000), // Dark green on white
surface = Color(0xFFF8F8F8), // Very light gray
background = LightBitchatPalette.background,
onBackground = Color(0xFF008000), // Dark green on off-white
surface = LightBitchatPalette.surface,
onSurface = Color(0xFF008000), // Dark green text
error = Color(0xFFCC0000), // Dark red for errors
surfaceVariant = LightBitchatPalette.surfaceVariant,
onSurfaceVariant = LightBitchatPalette.textSecondary,
outline = LightBitchatPalette.outline,
outlineVariant = LightBitchatPalette.outlineVariant,
error = LightBitchatPalette.accentRed,
onError = Color.White
)
@ -61,6 +72,7 @@ fun BitchatTheme(
}
val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme
val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette
val view = LocalView.current
SideEffect {
@ -83,9 +95,11 @@ fun BitchatTheme(
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
CompositionLocalProvider(LocalBitchatPalette provides palette) {
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
}

View File

@ -9,6 +9,26 @@ import androidx.compose.ui.unit.sp
// Base font size for consistent scaling across the app
internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability
/**
* Message body style. The generous leading (1.4x) is what gives the redesigned chat surface
* its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own
* metrics and lines sit too tightly for long paragraphs.
*/
val MessageBodyTextStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Normal,
fontSize = BASE_FONT_SIZE.sp,
lineHeight = (BASE_FONT_SIZE + 6).sp
)
/** Sender label above a message group. Single line, never wraps. */
val MessageSenderTextStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
fontSize = BASE_FONT_SIZE.sp,
lineHeight = (BASE_FONT_SIZE + 5).sp
)
// Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency
val Typography = Typography(
bodyLarge = TextStyle(
@ -21,7 +41,7 @@ val Typography = Typography(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Normal,
fontSize = BASE_FONT_SIZE.sp,
lineHeight = (BASE_FONT_SIZE + 3).sp
lineHeight = (BASE_FONT_SIZE + 6).sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Monospace,
@ -35,12 +55,31 @@ val Typography = Typography(
fontSize = (BASE_FONT_SIZE + 3).sp,
lineHeight = (BASE_FONT_SIZE + 9).sp
),
// Previously unset, which leaked the Roboto default into onboarding + sheet titles.
headlineLarge = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = (BASE_FONT_SIZE + 13).sp,
lineHeight = (BASE_FONT_SIZE + 21).sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = (BASE_FONT_SIZE + 5).sp,
lineHeight = (BASE_FONT_SIZE + 13).sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = (BASE_FONT_SIZE + 1).sp,
lineHeight = (BASE_FONT_SIZE + 7).sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = (BASE_FONT_SIZE - 1).sp,
lineHeight = (BASE_FONT_SIZE + 5).sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,

View File

@ -96,7 +96,6 @@
<string name="pick_image">اختيار صورة</string>
<!-- ورقة حول التطبيق -->
<string name="about_tagline">دردشة شبكية لامركزية مع تشفير طرف إلى طرف</string>
<string name="about_offline_mesh_title">دردشة شبكية دون اتصال</string>
<string name="about_offline_mesh_desc">تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق.</string>
<string name="about_online_geohash_title">قنوات geohash عبر الإنترنت</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">ছবি নির্বাচন করুন</string>
<!-- সম্পর্কে শিট -->
<string name="about_tagline">এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট</string>
<string name="about_offline_mesh_title">অফলাইন মেশ চ্যাট</string>
<string name="about_offline_mesh_desc">ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়।</string>
<string name="about_online_geohash_title">অনলাইন জিওহ্যাশ চ্যানেল</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Bild auswählen</string>
<!-- Über‑Sheet -->
<string name="about_tagline">dezentraler Mesh‑Chat mit Ende‑zu‑Ende‑Verschlüsselung</string>
<string name="about_offline_mesh_title">Offline‑Mesh‑Chat</string>
<string name="about_offline_mesh_desc">Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen.</string>
<string name="about_online_geohash_title">Online‑Geohash‑Kanäle</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Elegir imagen</string>
<!-- Hoja Acerca de -->
<string name="about_tagline">chat mesh descentralizado con cifrado de extremo a extremo</string>
<string name="about_offline_mesh_title">Chat mesh sin conexión</string>
<string name="about_offline_mesh_desc">Comunícate directamente a través de Bluetooth LE sin internet o servidores. Los mensajes se retransmiten a través de dispositivos cercanos para extender el alcance.</string>
<string name="about_online_geohash_title">Canales geohash en línea</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">انتخاب تصویر</string>
<!-- برگهٔ درباره -->
<string name="about_tagline">گفت‌وگوی مِش غیرمتمرکز با رمزگذاری سرتاسری</string>
<string name="about_offline_mesh_title">گفت‌وگوی مِش آفلاین</string>
<string name="about_offline_mesh_desc">بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیام‌ها از طریق دستگاه‌های نزدیک رله می‌شوند تا بُرد افزایش یابد.</string>
<string name="about_online_geohash_title">کانال‌های geohash آنلاین</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Pumili ng larawan</string>
<!-- About sheet -->
<string name="about_tagline">desentralisadong mesh messaging na may end‑to‑end encryption</string>
<string name="about_offline_mesh_title">Offline Mesh Chat</string>
<string name="about_offline_mesh_desc">Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device.</string>
<string name="about_online_geohash_title">Online na Geohash na mga Channel</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Choisir une image</string>
<!-- Feuille À propos -->
<string name="about_tagline">messagerie mesh décentralisée avec chiffrement de bout en bout</string>
<string name="about_offline_mesh_title">Chat Mesh hors‑ligne</string>
<string name="about_offline_mesh_desc">Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée.</string>
<string name="about_online_geohash_title">Canaux Geohash en ligne</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">छवि चुनें</string>
<!-- अबाउट शीट -->
<string name="about_tagline">विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन</string>
<string name="about_offline_mesh_title">ऑफ़लाइन मेष चैट</string>
<string name="about_offline_mesh_desc">इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े।</string>
<string name="about_online_geohash_title">ऑनलाइन geohash चैनल</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Pilih gambar</string>
<!-- Sheet tentang -->
<string name="about_tagline">chat mesh terdesentralisasi dengan enkripsi end-to-end</string>
<string name="about_offline_mesh_title">Chat Mesh Offline</string>
<string name="about_offline_mesh_desc">Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan.</string>
<string name="about_online_geohash_title">Channel Geohash Online</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Scegli immagine</string>
<!-- Schermata informazioni -->
<string name="about_tagline">messaggistica mesh decentralizzata con crittografia end-to-end</string>
<string name="about_offline_mesh_title">Chat mesh offline</string>
<string name="about_offline_mesh_desc">Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata.</string>
<string name="about_online_geohash_title">Canali geohash online</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">画像を選択</string>
<!-- 概要シート -->
<string name="about_tagline">分散型メッシュチャット・エンドツーエンド暗号化</string>
<string name="about_offline_mesh_title">オフライン・メッシュチャット</string>
<string name="about_offline_mesh_desc">インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。</string>
<string name="about_online_geohash_title">オンライン Geohash チャンネル</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">სურათის არჩევა</string>
<!-- შიტი "შესახებ" -->
<string name="about_tagline">დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით</string>
<string name="about_offline_mesh_title">ოფლაინ mesh-ჩათი</string>
<string name="about_offline_mesh_desc">ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა.</string>
<string name="about_online_geohash_title">ონლაინ geohash არხები</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">이미지 선택</string>
<!-- 정보 시트 -->
<string name="about_tagline">종단간 암호화를 갖춘 탈중앙 메쉬 채팅</string>
<string name="about_offline_mesh_title">오프라인 메쉬 채팅</string>
<string name="about_offline_mesh_desc">인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다.</string>
<string name="about_online_geohash_title">온라인 geohash 채널</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Hifidy sary</string>
<!-- Momba ny takelaka -->
<string name="about_tagline">hafatra mesh tsy misy foibe misy fandokoana farany amin\'ny farany</string>
<string name="about_offline_mesh_title">Mesh Chat tsy an-tserasera</string>
<string name="about_offline_mesh_desc">Mifandraisa mivantana amin\'ny alalan\'ny Bluetooth LE tsy misy Internet na mpizara. Ny hafatra dia mamindra amin\'ny alalan\'ny fitaovana akaiky mba hanitarana ny elanelana.</string>
<string name="about_online_geohash_title">Fantsona Geohash an-tserasera</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">तस्वीर छान्नुहोस्</string>
<!-- अबाउट शीट -->
<string name="about_tagline">विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित</string>
<string name="about_offline_mesh_title">अफलाइन मेष च्याट</string>
<string name="about_offline_mesh_desc">इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ।</string>
<string name="about_online_geohash_title">अनलाइन Geohash च्यानलहरू</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Afbeelding kiezen</string>
<!-- Info-sheet -->
<string name="about_tagline">gedecentraliseerde meshcommunicatie met end-to-end-versleuteling</string>
<string name="about_offline_mesh_title">Offline meshchat</string>
<string name="about_offline_mesh_desc">Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten.</string>
<string name="about_online_geohash_title">Online geohash-kanalen</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">تصویر چُنو</string>
<!-- بارے شیٹ -->
<string name="about_tagline">اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ</string>
<string name="about_offline_mesh_title">آف لائن میش چیٹ</string>
<string name="about_offline_mesh_desc">انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔</string>
<string name="about_online_geohash_title">آن لائن geohash چینل</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Escolher imagem</string>
<!-- Sobre -->
<string name="about_tagline">chat mesh descentralizado com criptografia de ponta a ponta</string>
<string name="about_offline_mesh_title">Chat mesh offline</string>
<string name="about_offline_mesh_desc">Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance.</string>
<string name="about_online_geohash_title">Canais geohash online</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Escolher imagem</string>
<!-- Folha sobre -->
<string name="about_tagline">chat mesh descentralizado com encriptação ponto a ponto</string>
<string name="about_offline_mesh_title">Chat mesh offline</string>
<string name="about_offline_mesh_desc">Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance.</string>
<string name="about_online_geohash_title">Canais geohash online</string>

View File

@ -86,7 +86,6 @@
<string name="close_with_emoji">❌ Закрыть</string>
<string name="pick_image">Выбрать изображение</string>
<string name="about_tagline">децентрализованный mesh‑чат со сквозным шифрованием</string>
<string name="about_offline_mesh_title">Оффлайн Mesh‑чат</string>
<string name="about_offline_mesh_desc">Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами.</string>
<string name="about_online_geohash_title">Онлайн‑каналы Geohash</string>

View File

@ -86,7 +86,6 @@
<string name="close_with_emoji">❌ Stäng</string>
<string name="pick_image">Välj bild</string>
<string name="about_tagline">decentraliserad mesh‑meddelanden med ända‑till‑ända‑kryptering</string>
<string name="about_offline_mesh_title">Offline Mesh‑chatt</string>
<string name="about_offline_mesh_desc">Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten.</string>
<string name="about_online_geohash_title">Online Geohash‑kanaler</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">เลือกภาพ</string>
<!-- แผ่นข้อมูลเกี่ยวกับ -->
<string name="about_tagline">แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง</string>
<string name="about_offline_mesh_title">แชทเมชออฟไลน์</string>
<string name="about_offline_mesh_desc">สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ</string>
<string name="about_online_geohash_title">ช่อง geohash ออนไลน์</string>

View File

@ -86,7 +86,6 @@
<string name="close_with_emoji">❌ Kapat</string>
<string name="pick_image">Görsel seç</string>
<string name="about_tagline">uçtan uca şifrelemeli merkezsiz mesh mesajlaşma</string>
<string name="about_offline_mesh_title">Çevrimdışı Mesh Sohbeti</string>
<string name="about_offline_mesh_desc">İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır.</string>
<string name="about_online_geohash_title">Çevrimiçi Geohash Kanalları</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">تصویر منتخب کریں</string>
<!-- تفصیلات شیٹ -->
<string name="about_tagline">غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن</string>
<string name="about_offline_mesh_title">آف لائن میش چیٹ</string>
<string name="about_offline_mesh_desc">انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔</string>
<string name="about_online_geohash_title">آن لائن geohash چینلز</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">Chọn hình ảnh</string>
<!-- Sheet giới thiệu -->
<string name="about_tagline">trò chuyện mesh phi tập trung với mã hóa đầu cuối</string>
<string name="about_offline_mesh_title">Trò chuyện Mesh ngoại tuyến</string>
<string name="about_offline_mesh_desc">Liên lạc trực tiếp qua Bluetooth LE mà không cần Internet hoặc máy chủ. Tin nhắn được chuyển tiếp qua các thiết bị gần để mở rộng phạm vi.</string>
<string name="about_online_geohash_title">Kênh Geohash trực tuyến</string>

View File

@ -96,7 +96,6 @@
<string name="pick_image">选择图片</string>
<!-- 关于 -->
<string name="about_tagline">去中心化 mesh 消息,端到端加密</string>
<string name="about_offline_mesh_title">离线 Mesh 聊天</string>
<string name="about_offline_mesh_desc">无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。</string>
<string name="about_online_geohash_title">在线 Geohash 频道</string>

View File

@ -109,7 +109,50 @@
<string name="cd_pick_file">Pick file</string>
<!-- About sheet -->
<string name="about_tagline">decentralized mesh messaging with end-to-end encryption</string>
<string name="about_tagline">The sidegroup chat</string>
<!-- About sheet: tabs -->
<string name="about_tab_how_to_use">How to use</string>
<string name="about_tab_basic_info">Basic info</string>
<!-- About sheet: how-to-use instructions -->
<string name="about_how_to_use_heading">How To Use</string>
<string name="about_howto_nickname">Set your nickname by tapping it</string>
<string name="about_howto_channels">Tap the channel name to change channels</string>
<string name="about_howto_people">Tap the people icon to see who\'s in the channel</string>
<string name="about_howto_bookmark">Bookmark a channel to keep it in your list</string>
<string name="about_howto_mention">Type @ to mention someone</string>
<string name="about_howto_commands">Type / to see the available commands</string>
<string name="about_howto_panic">Triple-tap the logo to wipe all data</string>
<!-- About sheet: section labels -->
<string name="about_section_about">About</string>
<string name="about_section_theme">Theme</string>
<string name="about_section_settings">Settings</string>
<!-- About sheet: feature list -->
<string name="about_offline_comm_title">Offline Communication</string>
<string name="about_offline_comm_desc">Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range.</string>
<string name="about_local_channels_title">Local Channels</string>
<string name="about_local_channels_desc">Geohash channels connect you with people nearby, extending the mesh over public relays.</string>
<string name="about_no_tracking_title">No Tracking</string>
<string name="about_no_tracking_desc">No servers, accounts, or data collection.</string>
<string name="about_ephemeral_title">Ephemeral Identity</string>
<string name="about_ephemeral_desc">A new peer ID is generated regularly.</string>
<string name="about_panic_title">Panic Mode</string>
<string name="about_panic_desc">Triple-tap the logo to instantly clear all data.</string>
<!-- About sheet: security warning -->
<string name="about_warning_title">Warning</string>
<string name="about_warning_body">Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears.</string>
<!-- About sheet: settings labels -->
<string name="about_tor_title">Tor Network</string>
<string name="about_difficulty">Difficulty</string>
<string name="about_difficulty_value">%1$d bits • %2$s</string>
<string name="about_tor_connected">Connected (%1$d%%)</string>
<string name="about_tor_disconnected">Disconnected</string>
<string name="about_offline_mesh_title">Offline Mesh Chat</string>
<string name="about_offline_mesh_desc">Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range.</string>
<string name="about_online_geohash_title">Online Geohash Channels</string>
@ -213,7 +256,24 @@
<!-- Location channels sheet -->
<string name="location_channels_title">#location channels</string>
<string name="location_channels_heading">Location Channels</string>
<string name="location_channels_desc">chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy.</string>
<!-- Location channels sheet: sections and Tor routing -->
<string name="section_bookmarked">Bookmarked</string>
<string name="section_nearby">Nearby</string>
<string name="location_tor_routing_title">Tor Routing</string>
<string name="location_tor_routing_desc">Hides your IP for location channels.</string>
<string name="badge_recommended">Recommended</string>
<string name="remove_location_access">Remove location access</string>
<!-- People sheet -->
<string name="people_count_title">People (%1$d)</string>
<string name="section_on_location">On location</string>
<string name="section_teleported_in">Teleported in</string>
<string name="section_channels">Channels</string>
<string name="section_mesh">Mesh</string>
<string name="grant_location_permission">grant location permission</string>
<string name="location_permission_denied">location permission denied. enable in settings to use location channels.</string>
@ -378,7 +438,7 @@
<string name="you_suffix"> (you)</string>
<string name="pan_zoom_instruction">pan and zoom to select a geohash</string>
<string name="select">select</string>
<string name="type_a_message_placeholder">type a message...</string>
<string name="type_a_message_placeholder">Type a message…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">mention</string>
<string name="image_counter">%1$d / %2$d</string>

View File

@ -1,10 +1,16 @@
package com.bitchat.android.ui
import androidx.compose.ui.text.SpanStyle
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.theme.DarkBitchatPalette
import com.bitchat.android.ui.theme.LightBitchatPalette
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatUIUtilsTest {
@ -12,33 +18,298 @@ class ChatUIUtilsTest {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
private val palette = DarkBitchatPalette
private fun message(
content: String,
sender: String = "alice",
powDifficulty: Int? = null,
) = BitchatMessage(
sender = sender,
content = content,
timestamp = Date(0),
powDifficulty = powDifficulty,
)
// MARK: - Timestamp metadata
@Test
fun `text message metadata separates PoW badge with one space`() {
val message = BitchatMessage(
sender = "alice",
content = "hello",
timestamp = Date(0),
powDifficulty = 12,
)
assertEquals(
"00:00:00 ⛨12b",
formatTextMessageMetadata(message, timeFormatter).text,
formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text,
)
}
@Test
fun `text message metadata omits non-positive PoW difficulty`() {
val message = BitchatMessage(
sender = "alice",
content = "hello",
timestamp = Date(0),
powDifficulty = 0,
)
assertEquals(
"00:00:00",
formatTextMessageMetadata(message, timeFormatter).text,
formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text,
)
}
// MARK: - Body with inline trailing timestamp
@Test
fun `body appends timestamp inline after the message text`() {
val body = formatTextMessageBody(
message = message("hello there"),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
)
assertEquals("hello there 00:00:00", body.text)
}
@Test
fun `body appends PoW badge after the inline timestamp`() {
val body = formatTextMessageBody(
message = message("mined", powDifficulty = 8),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
)
assertEquals("mined 00:00:00 ⛨8b", body.text)
}
@Test
fun `body can omit the timestamp for callers that render it separately`() {
val body = formatTextMessageBody(
message = message("hello there"),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
assertEquals("hello there", body.text)
}
@Test
fun `body renders plain text in the neutral palette color, not terminal green`() {
val body = formatTextMessageBody(
message = message("plain words"),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val textStyle = body.spanStyles.first { it.start == 0 }
assertEquals(palette.textPrimary, textStyle.item.color)
}
@Test
fun `body does not bold plain text for the sender's own messages`() {
// Regression guard: the old renderer bolded the entire body when the message was yours,
// and again when you were mentioned, which is what made busy channels unreadable.
val body = formatTextMessageBody(
message = message("my own words", sender = "bob"),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
assertTrue(
"no span should force a bold weight",
body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true }
)
}
// MARK: - Mention chips
private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) =
body.spanStyles.filter { it.item.background.isSpecified() }
private fun androidx.compose.ui.graphics.Color.isSpecified() =
this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f
@Test
fun `mention renders as a single contiguous background chip`() {
val body = formatTextMessageBody(
message = message("hey @carol#04af what's up"),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val chips = mentionChipSpans(body)
assertEquals("expected exactly one chip", 1, chips.size)
// The chip must cover "@carol#04af" as one run so it paints without seams.
val chip = chips.single()
assertEquals("@carol#04af", body.text.substring(chip.start, chip.end))
}
@Test
fun `mention targeting the current user uses the orange accent`() {
val body = formatTextMessageBody(
message = message("ping @bob now"),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val chip = mentionChipSpans(body).single()
assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background)
val nameStyle: SpanStyle = body.spanStyles
.first { it.start == chip.start && it.item.color == palette.accentOrange }
.item
assertEquals(palette.accentOrange, nameStyle.color)
}
@Test
fun `mention of another user is tinted by that user's own peer color`() {
val body = formatTextMessageBody(
message = message("cc @carol#04af"),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
val expected = colorForPeerSeed("carol#04af", palette.isDark)
val chip = mentionChipSpans(body).single()
assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background)
}
@Test
fun `message without mentions has no background chips`() {
val body = formatTextMessageBody(
message = message("no mentions in here"),
currentUserNickname = "bob",
palette = palette,
timeFormatter = timeFormatter,
includeTimestamp = false,
)
assertTrue(mentionChipSpans(body).isEmpty())
}
// MARK: - System / action messages
@Test
fun `system message uses a double-slash prefix and no brackets`() {
val text = formatSystemMessage(
message = message("Tor started. Routing all chats via Tor", sender = "system"),
palette = palette,
timeFormatter = timeFormatter,
).text
assertEquals("// Tor started. Routing all chats via Tor 00:00:00", text)
}
@Test
fun `system message is not italic`() {
// The old treatment was `* italic asterisks *`, which competed visually with real
// messages despite being lower-priority narration.
val annotated = formatSystemMessage(
message = message("tor restarting", sender = "system"),
palette = palette,
timeFormatter = timeFormatter,
)
assertTrue(annotated.spanStyles.all { it.item.fontStyle == null })
}
// MARK: - Sender label
@Test
fun `sender label drops angle brackets and dims the hash suffix`() {
val sender = formatTextMessageSender(
message = message("hi", sender = "carol#04af"),
currentUserNickname = "bob",
meshService = FakeMeshServiceIds.stub(),
palette = palette,
)
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" }
assertNotNull(suffixSpan.item.color)
assertTrue(
"suffix must be dimmer than the name",
suffixSpan.item.color.alpha < nameSpan.item.color.alpha
)
}
@Test
fun `sender label annotates the nickname for others but not for yourself`() {
val other = formatTextMessageSender(
message = message("hi", sender = "carol#04af"),
currentUserNickname = "bob",
meshService = FakeMeshServiceIds.stub(),
palette = palette,
)
assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size)
val mine = formatTextMessageSender(
message = message("hi", sender = "bob"),
currentUserNickname = "bob",
meshService = FakeMeshServiceIds.stub(),
palette = palette,
)
assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty())
}
// MARK: - Peer colors
@Test
fun `peer color hue is stable across light and dark, only chroma differs`() {
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for
// the redesigned neutral message body.
val dark = colorForPeerSeed("noise:abc", isDark = true)
val light = colorForPeerSeed("noise:abc", isDark = false)
val darkHsv = FloatArray(3)
val lightHsv = FloatArray(3)
rgbToHsv(dark.red, dark.green, dark.blue, darkHsv)
rgbToHsv(light.red, light.green, light.blue, lightHsv)
assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
}
@Test
fun `peer color avoids the orange hue reserved for self`() {
// Sweep a range of seeds; none may land within the reserved orange band.
repeat(500) { i ->
val color = colorForPeerSeed("noise:seed$i", isDark = true)
val hsv = FloatArray(3)
rgbToHsv(color.red, color.green, color.blue, hsv)
val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f)
assertTrue(
"seed$i resolved to ${hsv[0]}°, inside the reserved orange band",
distanceFromOrange >= 17f || hsv[1] < 0.01f
)
}
}
@Test
fun `light palette is not the dark palette`() {
assertNull(null)
assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary)
assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark)
}
private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) {
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
out[0] = when {
delta == 0f -> 0f
max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f
max == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}
out[1] = if (max == 0f) 0f else delta / max
out[2] = max
}
}