From e1d07f99f546590fcf248b51040d9168ce0c6f2b Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:56:59 +0200 Subject: [PATCH 01/37] first pass --- .../ui/component/button/BitChatBrandButton.kt | 4 +- .../com/bitchat/android/ui/AboutSections.kt | 622 ++++++++++++++++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 380 +++++------ .../java/com/bitchat/android/ui/ChatHeader.kt | 384 ++++++----- .../java/com/bitchat/android/ui/ChatScreen.kt | 96 +-- .../com/bitchat/android/ui/ChatUIUtils.kt | 482 ++++++-------- .../bitchat/android/ui/GeohashPeopleList.kt | 135 ++-- .../com/bitchat/android/ui/InputComponents.kt | 395 ++++++----- .../android/ui/LocationChannelsSheet.kt | 330 ++++++---- .../bitchat/android/ui/LocationNotesButton.kt | 7 +- .../android/ui/MatrixEncryptionAnimation.kt | 4 + .../bitchat/android/ui/MeshPeerListSheet.kt | 124 ++-- .../bitchat/android/ui/MessageComponents.kt | 139 ++-- .../com/bitchat/android/ui/MessageGrouping.kt | 79 +++ .../android/ui/VoiceInputComponents.kt | 2 +- .../android/ui/media/AudioMessageItem.kt | 12 +- .../android/ui/media/ImageMessageItem.kt | 12 +- .../android/ui/media/ImagePickerButton.kt | 9 +- .../android/ui/theme/BitchatPalette.kt | 106 +++ .../com/bitchat/android/ui/theme/Theme.kt | 42 +- .../bitchat/android/ui/theme/Typography.kt | 41 +- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-bn/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-fil/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hi/strings.xml | 1 - app/src/main/res/values-id/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ka/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-mg/strings.xml | 1 - app/src/main/res/values-ne/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pa-rPK/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-sv/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-ur/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh/strings.xml | 1 - app/src/main/res/values/strings.xml | 64 +- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 303 ++++++++- 49 files changed, 2547 insertions(+), 1251 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/AboutSections.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index 94529d77..15b2b40e 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -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), ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt new file mode 100644 index 00000000..73403b6d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -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 + ) + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f137ac63..ce170343 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index e91e30be..45657cef 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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 - ) - } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 78d5b571..340f180c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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() + } + ) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index d227eb41..2f945593 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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 { } /** - * 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?, 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() } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0bdc85ee..1eccc152 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -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)) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 91555b38..3fac4761 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -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 - 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, 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 ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 99ed10cc..26cdb767 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -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() } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 6ad9bdd8..af1198bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -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 ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 4ca8674f..52632f93 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -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, ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index ec137437..71bfc62a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 73fac04d..14f7b4f3 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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 = 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, 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), ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt new file mode 100644 index 00000000..f03e1b27 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c61f2f9a..1c005b46 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -56,7 +56,7 @@ fun VoiceRecordButton( Box( modifier = modifier - .size(32.dp) + .size(36.dp) .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 7c0a1ac6..5db41f17 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 35b0bbea..1691bb9b 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -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, 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 diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index eb1f5d86..172ad50d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -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) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt new file mode 100644 index 00000000..bd4280eb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -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 +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index 76ef3130..09d03959 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -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 + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt index a8c68a4b..66236747 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt @@ -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, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4d2ffec0..bd8949cb 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -96,7 +96,6 @@ اختيار صورة - دردشة شبكية لامركزية مع تشفير طرف إلى طرف دردشة شبكية دون اتصال تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق. قنوات geohash عبر الإنترنت diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index c5902ee2..f978e0ce 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -96,7 +96,6 @@ ছবি নির্বাচন করুন - এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট অফলাইন মেশ চ্যাট ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়। অনলাইন জিওহ্যাশ চ্যানেল diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9002f9bd..9fe6301e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -96,7 +96,6 @@ Bild auswählen - dezentraler Mesh‑Chat mit Ende‑zu‑Ende‑Verschlüsselung Offline‑Mesh‑Chat Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen. Online‑Geohash‑Kanäle diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cf6b336e..89d907da 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -96,7 +96,6 @@ Elegir imagen - chat mesh descentralizado con cifrado de extremo a extremo Chat mesh sin conexión 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. Canales geohash en línea diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 84273529..5f895797 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -96,7 +96,6 @@ انتخاب تصویر - گفت‌وگوی مِش غیرمتمرکز با رمزگذاری سرتاسری گفت‌وگوی مِش آفلاین بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیام‌ها از طریق دستگاه‌های نزدیک رله می‌شوند تا بُرد افزایش یابد. کانال‌های geohash آنلاین diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 65e4cbd2..dc231763 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -96,7 +96,6 @@ Pumili ng larawan - desentralisadong mesh messaging na may end‑to‑end encryption Offline Mesh Chat Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device. Online na Geohash na mga Channel diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aee12bf1..9d2b515f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -96,7 +96,6 @@ Choisir une image - messagerie mesh décentralisée avec chiffrement de bout en bout Chat Mesh hors‑ligne Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée. Canaux Geohash en ligne diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 926cc3a4..3ec6ac27 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -96,7 +96,6 @@ छवि चुनें - विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन ऑफ़लाइन मेष चैट इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े। ऑनलाइन geohash चैनल diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index bb15e3bb..048d40b2 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -96,7 +96,6 @@ Pilih gambar - chat mesh terdesentralisasi dengan enkripsi end-to-end Chat Mesh Offline Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan. Channel Geohash Online diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c10a8575..434f1dc6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -96,7 +96,6 @@ Scegli immagine - messaggistica mesh decentralizzata con crittografia end-to-end Chat mesh offline Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata. Canali geohash online diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index eb9d6ba1..1a3df392 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -96,7 +96,6 @@ 画像を選択 - 分散型メッシュチャット・エンドツーエンド暗号化 オフライン・メッシュチャット インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。 オンライン Geohash チャンネル diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 1e3187d0..cf08947b 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -96,7 +96,6 @@ სურათის არჩევა - დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით ოფლაინ mesh-ჩათი ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა. ონლაინ geohash არხები diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 829cd1f5..5be26395 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -96,7 +96,6 @@ 이미지 선택 - 종단간 암호화를 갖춘 탈중앙 메쉬 채팅 오프라인 메쉬 채팅 인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다. 온라인 geohash 채널 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index a7edd747..68b640f9 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -96,7 +96,6 @@ Hifidy sary - hafatra mesh tsy misy foibe misy fandokoana farany amin\'ny farany Mesh Chat tsy an-tserasera 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. Fantsona Geohash an-tserasera diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index f1dbb3c6..d460c037 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -96,7 +96,6 @@ तस्वीर छान्नुहोस् - विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित अफलाइन मेष च्याट इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ। अनलाइन Geohash च्यानलहरू diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6a014f87..4cf60a3d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -96,7 +96,6 @@ Afbeelding kiezen - gedecentraliseerde meshcommunicatie met end-to-end-versleuteling Offline meshchat Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten. Online geohash-kanalen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3b94bd8c..553e35ca 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -96,7 +96,6 @@ تصویر چُنو - اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ آف لائن میش چیٹ انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔ آن لائن geohash چینل diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 45f41512..86e238cb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -96,7 +96,6 @@ Escolher imagem - chat mesh descentralizado com criptografia de ponta a ponta Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance. Canais geohash online diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 274bbaae..2aeb5352 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -96,7 +96,6 @@ Escolher imagem - chat mesh descentralizado com encriptação ponto a ponto Chat mesh offline Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance. Canais geohash online diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1d27acd..76b332ff 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -86,7 +86,6 @@ ❌ Закрыть Выбрать изображение - децентрализованный mesh‑чат со сквозным шифрованием Оффлайн Mesh‑чат Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами. Онлайн‑каналы Geohash diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index aa3194ed..e465e4da 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -86,7 +86,6 @@ ❌ Stäng Välj bild - decentraliserad mesh‑meddelanden med ända‑till‑ända‑kryptering Offline Mesh‑chatt Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten. Online Geohash‑kanaler diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 5b2ba927..33d45309 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -96,7 +96,6 @@ เลือกภาพ - แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง แชทเมชออฟไลน์ สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ ช่อง geohash ออนไลน์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d27d1b59..0eccd05f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -86,7 +86,6 @@ ❌ Kapat Görsel seç - uçtan uca şifrelemeli merkezsiz mesh mesajlaşma Çevrimdışı Mesh Sohbeti İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır. Çevrimiçi Geohash Kanalları diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 1d8399de..df6d1f4f 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -96,7 +96,6 @@ تصویر منتخب کریں - غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن آف لائن میش چیٹ انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔ آن لائن geohash چینلز diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 6ab72813..b5cd2a6e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -96,7 +96,6 @@ Chọn hình ảnh - trò chuyện mesh phi tập trung với mã hóa đầu cuối Trò chuyện Mesh ngoại tuyến 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. Kênh Geohash trực tuyến diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8a28e5f7..05143855 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -96,7 +96,6 @@ 选择图片 - 去中心化 mesh 消息,端到端加密 离线 Mesh 聊天 无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。 在线 Geohash 频道 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e674112c..e20a4b49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,7 +109,50 @@ Pick file - decentralized mesh messaging with end-to-end encryption + The sidegroup chat + + + How to use + Basic info + + + How To Use + Set your nickname by tapping it + Tap the channel name to change channels + Tap the people icon to see who\'s in the channel + Bookmark a channel to keep it in your list + Type @ to mention someone + Type / to see the available commands + Triple-tap the logo to wipe all data + + + About + Theme + Settings + + + Offline Communication + Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. + Local Channels + Geohash channels connect you with people nearby, extending the mesh over public relays. + No Tracking + No servers, accounts, or data collection. + Ephemeral Identity + A new peer ID is generated regularly. + Panic Mode + Triple-tap the logo to instantly clear all data. + + + Warning + Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears. + + + Tor Network + Difficulty + %1$d bits • %2$s + Connected (%1$d%%) + Disconnected + Offline Mesh Chat Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range. Online Geohash Channels @@ -213,7 +256,24 @@ #location channels + Location Channels 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. + + + Bookmarked + Nearby + Tor Routing + Hides your IP for location channels. + Recommended + Remove location access + + + People (%1$d) + On location + Teleported in + Channels + Mesh + grant location permission location permission denied. enable in settings to use location channels. @@ -378,7 +438,7 @@ (you) pan and zoom to select a geohash select - type a message... + Type a message… @%1$s mention %1$d / %2$d diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 304166c0..44072412 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -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 + } } From ea0660e7173e10f9524b7772f412554f5e5c55f5 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:12:19 +0200 Subject: [PATCH 02/37] pass 2 --- .../com/bitchat/android/ui/AboutSections.kt | 15 +- .../java/com/bitchat/android/ui/AboutSheet.kt | 1 - .../java/com/bitchat/android/ui/ChatHeader.kt | 60 ++++--- .../com/bitchat/android/ui/ChatUIUtils.kt | 9 +- .../android/ui/LocationChannelsSheet.kt | 4 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 4 +- .../bitchat/android/ui/MessageComponents.kt | 24 ++- .../android/ui/media/AudioMessageItem.kt | 2 +- .../android/ui/media/ImageMessageItem.kt | 2 +- app/src/main/res/values-ar/strings.xml | 5 - app/src/main/res/values-bn/strings.xml | 5 - app/src/main/res/values-de/strings.xml | 5 - app/src/main/res/values-es/strings.xml | 5 - app/src/main/res/values-fa/strings.xml | 5 - app/src/main/res/values-fil/strings.xml | 5 - app/src/main/res/values-fr/strings.xml | 5 - app/src/main/res/values-hi/strings.xml | 5 - app/src/main/res/values-id/strings.xml | 5 - app/src/main/res/values-it/strings.xml | 5 - app/src/main/res/values-ja/strings.xml | 5 - app/src/main/res/values-ka/strings.xml | 5 - app/src/main/res/values-ko/strings.xml | 5 - app/src/main/res/values-mg/strings.xml | 5 - app/src/main/res/values-ne/strings.xml | 5 - app/src/main/res/values-nl/strings.xml | 5 - app/src/main/res/values-pa-rPK/strings.xml | 5 - app/src/main/res/values-pt-rBR/strings.xml | 5 - app/src/main/res/values-pt/strings.xml | 5 - app/src/main/res/values-ru/strings.xml | 5 - app/src/main/res/values-sv/strings.xml | 5 - app/src/main/res/values-th/strings.xml | 5 - app/src/main/res/values-tr/strings.xml | 5 - app/src/main/res/values-ur/strings.xml | 5 - app/src/main/res/values-vi/strings.xml | 5 - app/src/main/res/values-zh/strings.xml | 5 - app/src/main/res/values/strings.xml | 26 +-- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 13 +- .../bitchat/android/ui/MessageGroupingTest.kt | 152 ++++++++++++++++++ 38 files changed, 240 insertions(+), 202 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index 73403b6d..ae1b79a0 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -46,6 +46,7 @@ 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.unit.IntOffset import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -206,7 +207,9 @@ internal fun AboutTabBar( HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) Box( modifier = Modifier - .offset(x = indicatorOffset) + // Lambda overload: the offset is animated every frame, and the non-lambda + // version would invalidate composition rather than just layout. + .offset { IntOffset(x = indicatorOffset.roundToPx(), y = 0) } .width(tabWidth) .height(2.dp) .background(colorScheme.primary) @@ -355,8 +358,8 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { val features = listOf( Triple( Icons.Filled.WifiOff, - R.string.about_offline_comm_title, - R.string.about_offline_comm_desc + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc ), Triple( Icons.Outlined.Lock, @@ -365,8 +368,8 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { ), Triple( Icons.Outlined.Public, - R.string.about_local_channels_title, - R.string.about_local_channels_desc + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc ), Triple( Icons.Outlined.VisibilityOff, @@ -380,7 +383,7 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { ), Triple( Icons.Filled.DeleteForever, - R.string.about_panic_title, + R.string.about_emergency_title, R.string.about_panic_desc ), ) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index ce170343..2049b518 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -246,7 +246,6 @@ fun AboutSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - val isDark = palette.isDark var selectedTab by remember { mutableStateOf(AboutTab.HowToUse) } if (isPresented) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 45657cef..adda2378 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -120,26 +120,30 @@ fun NoiseSessionIcon( sessionState: String?, modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current + // The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with + // an all-but-transparent RGB - the icons were effectively invisible. They now use the + // palette's secondary text colour. val (icon, color, contentDescription) = when (sessionState) { "uninitialized" -> Triple( Icons.Outlined.NoEncryption, - Color(0x87878700), // Grey - ready to establish + palette.textSecondary, stringResource(R.string.cd_ready_for_handshake) ) "handshaking" -> Triple( Icons.Outlined.Sync, - Color(0x87878700), // Grey - in progress + palette.textSecondary, stringResource(R.string.cd_handshake_in_progress) ) "established" -> Triple( Icons.Filled.Lock, - Color(0xFFFF9500), // Orange - secure + palette.accentOrange, stringResource(R.string.cd_encrypted) ) else -> { // "failed" or any other state Triple( Icons.Outlined.Warning, - Color(0xFFFF4444), // Red - error + palette.accentRed, stringResource(R.string.cd_handshake_failed) ) } @@ -400,28 +404,36 @@ private fun MainHeader( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - // MARK: - Identity cluster - BitChatBrandButton( - onClick = onTitleClick, - onTripleClick = onTripleTitleClick, - contentDescription = stringResource(R.string.cd_open_about), - ) + // MARK: - Identity cluster. + // + // Weighted so it yields space to the status cluster rather than pushing it off screen. + // Compose measures unweighted children first, so the icons on the right always get the + // width they need and a long nickname simply scrolls within what is left. + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + BitChatBrandButton( + onClick = onTitleClick, + onTripleClick = onTripleTitleClick, + contentDescription = stringResource(R.string.cd_open_about), + modifier = Modifier.size(HeaderTapTarget), + ) - 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) - ) + 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) + ) - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) - - Spacer(modifier = Modifier.weight(1f)) + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange + ) + } // MARK: - Status cluster. Order matches the design: bookmark, channel, people. Row( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 2f945593..d1415fbe 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.text.font.FontWeight 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 com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatPalette import java.text.SimpleDateFormat @@ -49,11 +48,11 @@ fun getRSSIColor(rssi: Int): Color { fun formatTextMessageSender( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, + myPeerID: String, palette: BitchatPalette ): AnnotatedString { val builder = AnnotatedString.Builder() - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) 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) @@ -213,13 +212,13 @@ fun formatSystemMessage( fun formatMessageHeaderAnnotatedString( message: BitchatMessage, currentUserNickname: String, - meshService: MeshService, + myPeerID: String, palette: BitchatPalette, timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), includeSender: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + val isSelf = message.isFromSelf(currentUserNickname, myPeerID) if (message.sender == "system") { return formatSystemMessage(message, palette, timeFormatter) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 26cdb767..98e1fb40 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -298,7 +298,7 @@ fun LocationChannelsSheet( // Bookmarked geohashes if (bookmarks.isNotEmpty()) { item(key = "bookmarked_header") { - SheetSectionLabel(text = stringResource(R.string.section_bookmarked)) + SheetSectionLabel(text = stringResource(R.string.bookmarked)) } items(bookmarks) { gh -> val level = levelForLength(gh.length) @@ -571,7 +571,7 @@ fun LocationChannelsSheet( item(key = "location_toggle") { SheetDestructiveButton( text = if (locationServicesEnabled) { - stringResource(R.string.remove_location_access) + stringResource(R.string.disable_location_services) } else { stringResource(R.string.enable_location_services) }, diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 71bfc62a..a6c0d63d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -130,7 +130,7 @@ fun MeshPeerListSheet( // Channels section if (joinedChannels.isNotEmpty()) { item(key = "channels_header") { - SheetSectionLabel(text = stringResource(R.string.section_channels)) + SheetSectionLabel(text = stringResource(R.string.channels)) } items( @@ -316,7 +316,7 @@ fun PeopleSection( val palette = LocalBitchatPalette.current Column(modifier = modifier) { - SheetSectionLabel(text = stringResource(R.string.section_mesh)) + SheetSectionLabel(text = stringResource(R.string.people)) if (connectedPeers.isEmpty()) { Text( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 14f7b4f3..7457c681 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -300,7 +300,7 @@ fun MessageItem( val headerText = formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, + myPeerID = meshService.myPeerID, palette = palette, timeFormatter = timeFormatter, includeSender = showSender @@ -368,15 +368,24 @@ fun MessageItem( .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) - .background(Color.Gray.copy(alpha = 0.6f), CircleShape) + .background(palette.surfaceVariant.copy(alpha = 0.85f), CircleShape) .clickable { onCancelTransfer?.invoke(message) }, contentAlignment = Alignment.Center ) { - Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp)) + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.cd_cancel), + tint = palette.textPrimary, + modifier = Modifier.size(14.dp) + ) } } } else { - Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray) + Text( + text = stringResource(R.string.file_unavailable), + fontFamily = FontFamily.Monospace, + color = palette.textTertiary + ) } } } @@ -468,7 +477,7 @@ internal fun TextMessageLayout( formatTextMessageSender( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, + myPeerID = myPeerId, palette = palette, ) } @@ -548,6 +557,7 @@ internal fun TextMessageLayout( @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current when (status) { is DeliveryStatus.Sending -> { @@ -577,7 +587,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) { Text( text = stringResource(R.string.status_delivered), fontSize = 10.sp, - color = Color(0xFF007AFF), // Blue + color = palette.accentBlue, fontWeight = FontWeight.Bold ) } @@ -585,7 +595,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) { Text( text = stringResource(R.string.status_failed), fontSize = 10.sp, - color = Color.Red.copy(alpha = 0.8f) + color = palette.accentRed ) } is DeliveryStatus.PartiallyDelivered -> { diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 5db41f17..dc52f845 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -53,7 +53,7 @@ fun AudioMessageItem( val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, + myPeerID = meshService.myPeerID, palette = palette, timeFormatter = timeFormatter, includeSender = showSender diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 1691bb9b..01d119fe 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -51,7 +51,7 @@ fun ImageMessageItem( val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, + myPeerID = meshService.myPeerID, palette = palette, timeFormatter = timeFormatter, includeSender = showSender diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index bd8949cb..5ad12ff2 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -72,7 +72,6 @@ رجوع - القناة: %1$s مغادرة يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة @@ -102,7 +101,6 @@ تواصل مع أشخاص بالقرب منك عبر قنوات تعتمد على geohash. وسّع الشبكة عبر مرحلات الإنترنت العامة. تشفير طرف إلى طرف الرسائل الخاصة مشفّرة. رسائل القنوات عامة. - المظهر النظام فاتح داكن @@ -126,7 +124,6 @@ حالة Tor: %1$s، التمهيد %2$d%% آخر: %1$s مسح البيانات الطارئ - تلميح: انقر ثلاث مرات على عنوان التطبيق لمسح كل البيانات المحفوظة – بما في ذلك الرسائل والمفاتيح والإعدادات. إعدادات التصحيح مفتوح المصدر • الخصوصية أولاً • لامركزي إغلاق @@ -146,7 +143,6 @@ فتح الخريطة إزالة الإشارة المرجعية انتقال - محدد مغادرة القناة يمكن الوصول عبر Nostr مفضل (غير متصل) @@ -285,7 +281,6 @@ الأذونات منح الأذونات bitchat لا يتتبع موقعك - الأشخاص لا أحد هنا… (أنت) حرّك وقرّب لاختيار geohash diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index f978e0ce..76c13dd2 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -72,7 +72,6 @@ ফিরে যান - চ্যানেল: %1$s ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা @@ -102,7 +101,6 @@ জিওহ্যাশ-ভিত্তিক চ্যানেলের মাধ্যমে আপনার এলাকার মানুষদের সাথে সংযুক্ত হন। পাবলিক ইন্টারনেট রিলেগুলির মাধ্যমে মেশ বিস্তৃত করুন। এন্ড-টু-এন্ড এনক্রিপশন ব্যক্তিগত বার্তাগুলি এনক্রিপ্ট করা। চ্যানেল বার্তাগুলি পাবলিক। - চেহারা সিস্টেম হালকা অন্ধকার @@ -126,7 +124,6 @@ Tor অবস্থা: %1$s, বুটস্ট্র্যাপ %2$d%% শেষ: %1$s জরুরী ডেটা ক্লিয়ার - টিপ: সংরক্ষিত সমস্ত ডেটা – বার্তা, কী এবং সেটিংস সহ – সাফ করতে অ্যাপ শিরোনামে তিনবার ট্যাপ করুন। ডিবাগ সেটিংস ওপেন সোর্স • গোপনীয়তা প্রথম • বিকেন্দ্রীভূত বন্ধ করুন @@ -146,7 +143,6 @@ মানচিত্র খুলুন বুকমার্ক সরান টেলিপোর্ট - নির্বাচিত চ্যানেল ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় প্রিয় (অফলাইন) @@ -285,7 +281,6 @@ অনুমতি অনুমতি দিন bitchat আপনার অবস্থান ট্র্যাক করে না - মানুষ কেউ আশেপাশে নেই … (আপনি) জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9fe6301e..23168837 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -72,7 +72,6 @@ zurück - Kanal: %1$s verlassen Über Nostr erreichbar Ungelesene private Nachrichten @@ -102,7 +101,6 @@ Vernetze dich mit Menschen in deiner Umgebung über geohash‑basierte Kanäle. Erweitere das Mesh über öffentliche Internet‑Relays. Ende‑zu‑Ende‑Verschlüsselung Private Nachrichten sind verschlüsselt. Kanal‑Nachrichten sind öffentlich. - Darstellung System Hell Dunkel @@ -126,7 +124,6 @@ Tor‑Status: %1$s, Bootstrap %2$d%% Zuletzt: %1$s Notfall‑Datenlöschung - Tipp: Dreifach auf den App‑Titel tippen, um alle gespeicherten Daten – einschließlich Nachrichten, Schlüssel und Einstellungen – zu löschen. Debug‑Einstellungen Open Source • Privacy First • Dezentral Schließen @@ -146,7 +143,6 @@ Karte öffnen Lesezeichen entfernen Teleportieren - Ausgewählt Kanal verlassen Über Nostr erreichbar Favorit (offline) @@ -285,7 +281,6 @@ Berechtigungen Berechtigungen erteilen bitchat verfolgt NICHT deinen Standort - PERSONEN niemand da … (du) schwenken und zoomen, um einen Geohash auszuwählen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 89d907da..836f563a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -72,7 +72,6 @@ atrás - Canal: %1$s salir Accesible vía Nostr Mensajes privados sin leer @@ -102,7 +101,6 @@ Conéctate con personas cerca de ti a través de canales basados en geohash. Extiende la red mesh a través de repetidores de internet público. Cifrado de extremo a extremo Los mensajes privados están cifrados. Los mensajes de canal son públicos. - Apariencia Sistema Claro Oscuro @@ -126,7 +124,6 @@ Estado de Tor: %1$s, bootstrap %2$d%% Último: %1$s Borrado de datos de emergencia - Consejo: Toca tres veces en el título de la app para borrar todos los datos almacenados, incluyendo mensajes, claves y configuraciones. Configuraciones de depuración Código abierto • Privacidad primero • Descentralizado Cerrar @@ -146,7 +143,6 @@ Abrir mapa Quitar marcador Teletransportar - Seleccionado Salir del canal Accesible vía Nostr Favorito (sin conexión) @@ -285,7 +281,6 @@ Permisos Otorgar permisos bitchat NO rastrea tu ubicación - PERSONAS nadie por aquí… (tú) desplaza y hace zoom para seleccionar un geohash diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 5f895797..551ca8db 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -72,7 +72,6 @@ بازگشت - کانال: %1$s خروج قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده @@ -102,7 +101,6 @@ از طریق کانال‌های مبتنی بر geohash با افراد منطقهٔ خود ارتباط برقرار کنید. مِش را از طریق رله‌های عمومی اینترنت گسترش دهید. رمزگذاری سرتاسری پیام‌های خصوصی رمزگذاری می‌شوند. پیام‌های کانال عمومی هستند. - نما سامانه روشن تیره @@ -126,7 +124,6 @@ وضعیت Tor: %1$s، بوت‌استرپ %2$d%% آخرین: %1$s پاکسازی اضطراری داده - نکته: برای پاک کردن همهٔ داده‌های ذخیره‌شده — شامل پیام‌ها، کلیدها و تنظیمات — سه بار روی عنوان برنامه ضربه بزنید. تنظیمات اشکال‌زدایی متن‌باز • حریم خصوصی در اولویت • غیرمتمرکز بستن @@ -146,7 +143,6 @@ باز کردن نقشه حذف نشانک انتقال - انتخاب شد خروج از کانال قابل دسترس از طریق Nostr دلخواه (آفلاین) @@ -285,7 +281,6 @@ مجوزها اعطای مجوزها bitchat موقعیت شما را پیگیری نمی‌کند - افراد کسی در اطراف نیست … (شما) برای انتخاب geohash، جابه‌جا و زوم کنید diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index dc231763..526cc636 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -72,7 +72,6 @@ bumalik - channel: %1$s umalis Maabot sa Nostr Hindi nabasang pribadong mensahe @@ -102,7 +101,6 @@ Makipag‑ugnayan sa mga tao sa paligid gamit ang geohash‑based na mga channel. Palawakin ang mesh sa pampublikong internet relays. End‑to‑End Encryption Ang mga pribadong mensahe ay naka‑encrypt. Ang mga mensahe sa channel ay publiko. - hitsura system light dark @@ -126,7 +124,6 @@ katayuan ng Tor: %1$s, bootstrap %2$d%% Huli: %1$s Emergency Data Deletion - Tip: i‑tap ang pamagat ng app nang 3 beses para burahin ang lahat ng data (mga mensahe, susi, setting). Debug Settings Open Source • Privacy First • Desentralisado Isara @@ -146,7 +143,6 @@ Buksan ang mapa Alisin ang bookmark Teleport - Napili Umalis sa channel Maabot sa Nostr Paborito (offline) @@ -280,7 +276,6 @@ HINDI sinusubaybayan ng bitchat ang lokasyon mo @ · ⧉ - TAO walang tao sa paligid… (ikaw) i‑pan at i‑zoom para pumili ng geohash diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9d2b515f..8da0188f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -72,7 +72,6 @@ retour - canal : %1$s quitter Joignable via Nostr Messages privés non lus @@ -102,7 +101,6 @@ Discute avec des personnes proches via des canaux basés sur geohash. Étends le mesh grâce à des relais internet publics. Chiffrement de bout en bout Les messages privés sont chiffrés. Les messages de canal sont publics. - apparence système clair sombre @@ -126,7 +124,6 @@ statut Tor : %1$s, bootstrap %2$d%% Dernier : %1$s Suppression d’urgence des données - Astuce : tapote 3 fois le titre de l’app pour supprimer toutes les données (messages, clés, paramètres). Paramètres de debug Open Source • Privacy First • Décentralisé Fermer @@ -146,7 +143,6 @@ Ouvrir la carte Retirer le signet Téléporter - Sélectionné Quitter le canal Joignable via Nostr Favori (hors‑ligne) @@ -281,7 +277,6 @@ bitchat ne suit PAS ta position @ · ⧉ - PERSONNES personne aux alentours… (toi) déplacer et zoomer pour choisir un geohash diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 3ec6ac27..6921ace7 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -72,7 +72,6 @@ वापस - चैनल: %1$s छोड़ें Nostr के माध्यम से उपलब्ध अपठित निजी संदेश @@ -102,7 +101,6 @@ geohash आधारित चैनलों के माध्यम से अपने आस‑पास के लोगों से जुड़ें। सार्वजनिक इंटरनेट रिले के माध्यम से मेष का विस्तार करें। एंड‑टू‑एंड एन्क्रिप्शन निजी संदेश एन्क्रिप्टेड हैं। चैनल संदेश सार्वजनिक हैं। - रूप सिस्टम लाइट डार्क @@ -126,7 +124,6 @@ Tor स्थिति: %1$s, बूटस्ट्रैप %2$d%% अंतिम: %1$s आपातकालीन डेटा मिटाना - संकेत: सभी सहेजे डेटा – संदेश, कुंजियाँ और सेटिंग्स सहित – मिटाने के लिए ऐप शीर्षक पर तीन बार टैप करें। डिबग सेटिंग्स ओपन सोर्स • गोपनीयता पहले • विकेन्द्रीकृत बंद करें @@ -146,7 +143,6 @@ मानचित्र खोलें बुकमार्क हटाएँ टेलीपोर्ट - चयनित चैनल छोड़ें Nostr के माध्यम से उपलब्ध पसंदीदा (ऑफ़लाइन) @@ -285,7 +281,6 @@ अनुमतियाँ अनुमतियाँ दें bitchat आपकी लोकेशन ट्रैक नहीं करती - लोग आस‑पास कोई नहीं… (आप) geohash चुनने के लिए पैन/ज़ूम करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 048d40b2..c5eb612c 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -72,7 +72,6 @@ kembali - Channel: %1$s keluar Dapat dijangkau melalui Nostr Pesan pribadi yang belum dibaca @@ -102,7 +101,6 @@ Terhubung dengan orang-orang di area Anda melalui channel berbasis geohash. Perluas mesh melalui relay Internet publik. Enkripsi end-to-end Pesan pribadi dienkripsi. Pesan channel bersifat publik. - Tampilan Sistem Terang Gelap @@ -126,7 +124,6 @@ Status Tor: %1$s, Bootstrap %2$d%% Terakhir: %1$s Hapus data darurat - Tips: Ketuk tiga kali pada judul aplikasi untuk menghapus semua data tersimpan – termasuk pesan, kunci, dan pengaturan. Pengaturan debug Sumber Terbuka • Privasi Pertama • Terdesentralisasi Tutup @@ -146,7 +143,6 @@ Buka peta Hapus bookmark Teleportasi - Dipilih Keluar dari channel Dapat dijangkau melalui Nostr Favorit (offline) @@ -285,7 +281,6 @@ Izin Berikan izin bitchat TIDAK melacak lokasi Anda - ORANG tidak ada orang di sekitar … (Anda) geser dan zoom untuk memilih geohash diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 434f1dc6..8c157300 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -72,7 +72,6 @@ indietro - canale: %1$s esci Raggiungibile via Nostr Messaggi privati non letti @@ -102,7 +101,6 @@ Connettiti con persone nella tua area usando canali basati su geohash. Estendi la rete mesh usando relay internet pubblici. Crittografia end-to-end I messaggi privati sono crittografati. I messaggi dei canali sono pubblici. - aspetto sistema chiaro scuro @@ -126,7 +124,6 @@ Stato Tor: %1$s, bootstrap %2$d%% Ultimo: %1$s Eliminazione dati di emergenza - Suggerimento: fai triplo clic sul titolo dell\'app per eliminare in emergenza tutti i dati memorizzati, inclusi messaggi, chiavi e impostazioni. Impostazioni di debug Open Source • Privacy First • Decentralizzato Chiudi @@ -146,7 +143,6 @@ Apri mappa Rimuovi segnalibro Teletrasporta - Selezionato Esci dal canale Raggiungibile via Nostr Preferito offline @@ -285,7 +281,6 @@ permessi Concedi permessi bitchat NON traccia la tua posizione - PERSONE nessuno nei dintorni… (tu) scorri e ingrandisci per selezionare un geohash diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1a3df392..b272e51f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -72,7 +72,6 @@ 戻る - チャンネル: %1$s 退出 Nostr 経由で到達可能 未読のプライベートメッセージ @@ -102,7 +101,6 @@ Geohash ベースのチャンネルで近くの人々とつながります。公開インターネットのリレーを介してメッシュを拡張します。 エンドツーエンド暗号化 プライベートメッセージは暗号化されます。チャンネルメッセージは公開です。 - 表示 システム ライト ダーク @@ -126,7 +124,6 @@ Tor ステータス: %1$s、ブートストラップ %2$d%% 最終: %1$s 緊急データ消去 - ヒント: アプリタイトルを 3 回タップすると、メッセージ・鍵・設定を含む保存データをすべて消去します。 デバッグ設定 オープンソース • プライバシーファースト • 分散型 閉じる @@ -146,7 +143,6 @@ 地図を開く ブックマークを削除 テレポート - 選択済み チャンネルから退出 Nostr 経由で到達可能 お気に入り (オフライン) @@ -285,7 +281,6 @@ 権限 権限を付与 bitchat はあなたの位置情報を追跡しません - 人々 誰もいません… (あなた) パン/ズームして geohash を選択 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index cf08947b..65f27848 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -72,7 +72,6 @@ უკან - არხი: %1$s გასვლა მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები @@ -102,7 +101,6 @@ დაუკავშირდით ახლომდებარე ხალხს geohash-ზე დაფუძნებული არხებით. გააფართოვეთ mesh საჯარო ინტერნეტ რილეებით. ბოლო-მდე დაშიფვრა პირადი შეტყობინებები დაშიფრულია. არხის შეტყობინებები საჯაროა. - გარეგნობა სისტემა ღია მუქი @@ -126,7 +124,6 @@ Tor სტატუსი: %1$s, ჩატვირთვა %2$d%% ბოლო: %1$s სასწრაფო მონაცემების წაშლა - რჩევა: დააჭირეთ აპის სათაურს სამჯერ ყველა შენახული მონაცემის – შეტყობინებების, გასაღებებისა და პარამეტრების – წასაშლელად. დebug პარამეტრები ღია კოდი • კონფიდენციალობა პირველია • დეცენტრალიზებული დახურვა @@ -146,7 +143,6 @@ რუკის გახსნა სანიშნეს წაშლა ტელეპორტი - მონიშნული არხიდან გამოსვლა მისაწვდომია Nostr-ით რჩეული (ოფლაინ) @@ -285,7 +281,6 @@ ნებართვები ნებართვების მინიჭება bitchat არ აკონტროლებს თქვენს მდებარეობას - ხალხი არავინ არის ახლოს… (თქვენ) პანორამირება/ზუმი geohash-ის ასარჩევად diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5be26395..ee2838b6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -72,7 +72,6 @@ 뒤로 - 채널: %1$s 나가기 Nostr로 연결 가능 읽지 않은 개인 메시지 @@ -102,7 +101,6 @@ geohash 기반 채널을 통해 주변 사람들과 연결하세요. 공용 인터넷 릴레이로 메쉬를 확장합니다. 종단간 암호화 개인 메시지는 암호화됩니다. 채널 메시지는 공개입니다. - 모양 시스템 라이트 다크 @@ -126,7 +124,6 @@ Tor 상태: %1$s, 부트스트랩 %2$d%% 마지막: %1$s 긴급 데이터 삭제 - 팁: 저장된 모든 데이터(메시지, 키, 설정 포함)를 삭제하려면 앱 제목을 세 번 탭하세요. 디버그 설정 오픈 소스 • 프라이버시 우선 • 탈중앙 닫기 @@ -146,7 +143,6 @@ 지도 열기 북마크 제거 텔레포트 - 선택됨 채널 나가기 Nostr로 연결 가능 즐겨찾기 (오프라인) @@ -285,7 +281,6 @@ 권한 권한 허용 bitchat은 사용자의 위치를 추적하지 않습니다 - 사람들 주변에 아무도 없음 … (나) geohash를 선택하려면 이동하고 확대/축소하세요 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 68b640f9..ffec5957 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -72,7 +72,6 @@ miverina - fantsona: %1$s hiala Azo tratrarina amin\'ny alalan\'ny Nostr Hafatra manokana tsy voavaky @@ -102,7 +101,6 @@ Mifandray amin\'ny olona eo amin\'ny faritra misy anao amin\'ny fampiasana fantsona mifototra amin\'ny geohash. Manitatra ny mesh amin\'ny fampiasana relay Internet ho an\'ny daholobe. Fandokoana Farany amin\'ny Farany Voafono ny hafatra manokana. Hafatra ho an\'ny daholobe ny hafatra fantsona. - endrika rafitra mazava maizina @@ -126,7 +124,6 @@ Toe-javatra Tor: %1$s, bootstrap %2$d%% Farany: %1$s Famafana Angona Maika - Toro-hevitra: Tsindrio in-telo ny anaran\'ny fampiharana mba hamafana maika ny angona rehetra voatahiry ao anatin\'izany ny hafatra, ny lakile ary ny fametrahana. Fametrahana Debug Loharanom-baovao misokatra • Fiainana manokana voalohany • Tsy misy foibe Hanakatona @@ -146,7 +143,6 @@ Hanokatra sarintany Hanesorana bookmark Hamindra toerana - Voafidy Hiala amin\'ny fantsona Azo tratrarina amin\'ny alalan\'ny Nostr Ankafizina tsy an-tserasera @@ -289,7 +285,6 @@ Ny bitchat dia TSY manaraka ny toerananao @ · ⧉ - OLONA tsy misy olona manodidina... (ianao) ahetsika sy ataovy lehibe mba hifidianana geohash diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index d460c037..7c2bbf26 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -72,7 +72,6 @@ पछाडि - च्यानल: %1$s छोड्नुहोस् Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश @@ -102,7 +101,6 @@ Geohash आधारित च्यानलमार्फत नजिकका मानिसहरूसँग जोडिनुहोस्। सार्वजनिक इन्टरनेट रिलेलाई प्रयोग गरेर मेष विस्तार गर्नुहोस्। एन्ड‑टु‑एन्ड इन्क्रिप्सन निजी सन्देश इन्क्रिप्टेड हुन्छन्। च्यानल सन्देश सार्वजनिक हुन्छन्। - देखावट सिस्टम हल्का गाढा @@ -126,7 +124,6 @@ Tor स्थिति: %1$s, बुटस्ट्र्याप %2$d%% अन्तिम: %1$s आपतकालीन डेटा मेटाउने - सुझाव: एप शीर्षक तीन पटक ट्याप गरी सबै डेटा (सन्देश, कुञ्जी, सेटिङ) मेटाउनुहोस्। डिबग सेटिङ Open Source • Privacy First • विकेन्द्रित बन्द गर्नुहोस् @@ -146,7 +143,6 @@ नक्सा खोल्नुहोस् बुकमार्क हटाउनुहोस् टेलिपोर्ट - छानिएको च्यानल छोड्नुहोस् Nostr बाट पहुँचयोग्य मनपर्ने (अफलाइन) @@ -280,7 +276,6 @@ bitchat ले तपाईंको स्थान पछ्याउँदैन @ · ⧉ - मानिसहरू वरिपरि कोही छैन… (तपाईं) geohash छान्न प्यान र जुम गर्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4cf60a3d..d9de3a6d 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -72,7 +72,6 @@ terug - kanaal: %1$s verlaten Bereikbaar via Nostr Ongelezen privéberichten @@ -102,7 +101,6 @@ Maak contact met mensen in uw omgeving met geohash-gebaseerde kanalen. Breid het mesh uit met openbare internetrelays. End-to-end-versleuteling Privéberichten zijn versleuteld. Kanaalberichten zijn openbaar. - uiterlijk systeem licht donker @@ -126,7 +124,6 @@ Tor-status: %1$s, bootstrap %2$d%% Laatste: %1$s Noodgegevensverwijdering - Tip: Driedubbel klik op de app-titel om alle opgeslagen gegevens inclusief berichten, sleutels en instellingen noodmatig te verwijderen. Debug-instellingen Open Source • Privacy First • Gedecentraliseerd Sluiten @@ -146,7 +143,6 @@ Kaart openen Bladwijzer verwijderen Teleporteren - Geselecteerd Kanaal verlaten Bereikbaar via Nostr Offline favoriet @@ -285,7 +281,6 @@ toestemmingen Toestemmingen verlenen bitchat volgt NIET uw locatie - PERSONEN niemand in de buurt… (jij) pan en zoom om een geohash te selecteren diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 553e35ca..f7423ce5 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -72,7 +72,6 @@ واپس - چینل: %1$s باہر آؤ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام @@ -102,7 +101,6 @@ geohash بیسڈ چینلاں راہین اپنے علاقے دے لوکاں نال جڑو۔ پبلک انٹرنیٹ ریلے راہین میش نوں وسیع کرو۔ اینڈ ٹو اینڈ انکرپشن ذاتی پیغام انکرپٹڈ ہوندے نیں۔ چینل پیغام پبلک ہوندے نیں۔ - دِکھ سسٹم ہلکا گھُپ @@ -126,7 +124,6 @@ Tor حالت: %1$s، بوٹ اسٹرَیپ %2$d%% آخری: %1$s ایمرجنسی ڈیٹا صاف - ٹِپ: سارے محفوظ ڈیٹا (پیغام، کُنجیاں، سیٹنگا) مٹاؤن لئی ایپ دے عنوان تے تین وار ٹیپ کرو۔ ڈی بگ سیٹنگا اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کرو @@ -146,7 +143,6 @@ نقشہ کھولو بُک مارک ہٹاؤ ٹیلپورٹ - چُنے ہوۓ چینل چھوڑو Nostr راہین پہنچ ممکن اے پسندیدہ (آف لائن) @@ -285,7 +281,6 @@ اجازتاں اجازت دو bitchat توانڈی لوکیشن نئیں ٹریک کردا - لوگ کوئی وی نیڑے نئیں … (تُسی) geohash چُنن لئی پین تے زوم کرو diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 86e238cb..a350d3f7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -72,7 +72,6 @@ voltar - Canal: %1$s sair Acessível via Nostr Mensagens privadas não lidas @@ -102,7 +101,6 @@ Conecte-se com pessoas próximas através de canais baseados em geohash. Expanda a mesh por meio de relays públicos na internet. Criptografia de ponta a ponta Mensagens privadas são criptografadas. Mensagens de canal são públicas. - Aparência Sistema Claro Escuro @@ -126,7 +124,6 @@ Status do Tor: %1$s, bootstrap %2$d%% Último: %1$s Limpeza de dados de emergência - Dica: Toque 3 vezes no título do app para apagar todos os dados salvos – incluindo mensagens, chaves e configurações. Configurações de depuração Código aberto • Privacidade em primeiro lugar • Descentralizado Fechar @@ -146,7 +143,6 @@ Abrir mapa Remover favorito Teletransportar - Selecionado Sair do canal Acessível via Nostr Favorito (offline) @@ -285,7 +281,6 @@ Permissões Conceder permissões O bitchat NÃO rastreia sua localização - PESSOAS ninguém por perto… (você) arraste/amplie para selecionar um geohash diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2aeb5352..9751b733 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -72,7 +72,6 @@ voltar - Canal: %1$s sair Alcançável via Nostr Mensagens privadas não lidas @@ -102,7 +101,6 @@ Conecte-se com pessoas perto de si através de canais baseados em geohash. Estenda a mesh através de repetidores de internet pública. Encriptação ponto a ponto Mensagens privadas são encriptadas. Mensagens de canal são públicas. - Aparência Sistema Claro Escuro @@ -126,7 +124,6 @@ Estado do Tor: %1$s, bootstrap %2$d%% Último: %1$s Limpeza de dados de emergência - Dica: Toque três vezes no título da aplicação para apagar todos os dados guardados – incluindo mensagens, chaves e definições. Definições de depuração Código aberto • Privacidade primeiro • Descentralizado Fechar @@ -146,7 +143,6 @@ Abrir mapa Remover marcador Teletransportar - Selecionado Sair do canal Alcançável via Nostr Favorito (offline) @@ -285,7 +281,6 @@ Permissões Conceder permissões O bitchat NÃO rastreia a sua localização - PESSOAS ninguém por perto… (você) arraste e amplie para selecionar um geohash diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 76b332ff..5672cd00 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -64,7 +64,6 @@ Добавить закладку назад - канал: %1$s выйти Доступен через Nostr Непрочитанные личные @@ -92,7 +91,6 @@ Общайся с людьми поблизости через каналы на основе geohash. Расширяй mesh через публичные ретрансляторы. Сквозное шифрование Личные сообщения — зашифрованы. Канальные — публичны. - оформление система светлая тёмная @@ -116,7 +114,6 @@ статус Tor: %1$s, загрузка %2$d%% Последнее: %1$s Экстренное удаление данных - Совет: тройной тап по заголовку — удалить все данные (сообщения, ключи, настройки). Настройки отладки Open Source • Privacy First • Децентрализация Закрыть @@ -135,7 +132,6 @@ Открыть карту Удалить закладку Телепорт - Выбрано Покинуть канал Доступен через Nostr Избранное (оффлайн) @@ -264,7 +260,6 @@ bitchat НЕ отслеживает вашу геопозицию @ · ⧉ - ЛЮДИ никого рядом… (вы) прокрутите/масштабируйте для выбора geohash diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index e465e4da..a6df7a8e 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -64,7 +64,6 @@ Lägg till bokmärke tillbaka - kanal: %1$s lämna Nås via Nostr Olästa privata meddelanden @@ -92,7 +91,6 @@ Anslut med personer i närheten via geohash‑kanaler. Utöka mesh via publika internetreläer. Ända‑till‑ända‑kryptering Privata meddelanden är krypterade. Kanalmeddelanden är publika. - utseende system ljus mörk @@ -116,7 +114,6 @@ Tor‑status: %1$s, bootstrap %2$d%% Senast: %1$s Nödradering av data - Tips: tryck tre gånger på appens titel för att radera alla data (meddelanden, nycklar, inställningar). Felsökningsinställningar Open Source • Privacy First • Decentraliserad Stäng @@ -135,7 +132,6 @@ Öppna karta Ta bort bokmärke Teleportera - Vald Lämna kanal Nås via Nostr Favorit (offline) @@ -264,7 +260,6 @@ bitchat spårar INTE din plats @ · ⧉ - PERSONER ingen i närheten… (du) panorera och zooma för att välja geohash diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 33d45309..af9111fa 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -72,7 +72,6 @@ กลับ - ช่อง: %1$s ออก ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน @@ -102,7 +101,6 @@ เชื่อมต่อกับผู้คนใกล้คุณผ่านช่องที่อ้างอิง geohash ขยายเมชผ่านรีเลย์อินเทอร์เน็ตสาธารณะ การเข้ารหัสแบบปลายทางถึงปลายทาง ข้อความส่วนตัวถูกเข้ารหัส ข้อความในช่องเป็นสาธารณะ - ลักษณะ ระบบ สว่าง มืด @@ -126,7 +124,6 @@ สถานะ Tor: %1$s, การบูต %2$d%% ล่าสุด: %1$s ลบข้อมูลฉุกเฉิน - เคล็ดลับ: แตะชื่อแอปสามครั้งเพื่อลบข้อมูลที่บันทึกทั้งหมด – รวมทั้งข้อความ กุญแจ และการตั้งค่า การตั้งค่าดีบัก โอเพนซอร์ส • ความเป็นส่วนตัวมาก่อน • กระจายศูนย์ ปิด @@ -146,7 +143,6 @@ เปิดแผนที่ เอาที่คั่นหน้าออก เทเลพอร์ต - ที่เลือก ออกจากช่อง ติดต่อได้ผ่าน Nostr รายการโปรด (ออฟไลน์) @@ -285,7 +281,6 @@ สิทธิ์ ให้สิทธิ์ bitchat ไม่ติดตามตำแหน่งของคุณ - ผู้คน ไม่มีใครอยู่ใกล้… (คุณ) ลาก/ซูมเพื่อเลือก geohash diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0eccd05f..721b50c1 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -64,7 +64,6 @@ Yer imi ekle geri - kanal: %1$s ayrıl Nostr üzerinden ulaşılabilir Okunmamış özel mesaj @@ -92,7 +91,6 @@ Geohash tabanlı kanallarla yakınındaki insanlarla bağlantı kur. Herkese açık internet röleleriyle mesh’i genişlet. Uçtan Uca Şifreleme Özel mesajlar şifrelidir. Kanal mesajları herkese açıktır. - görünüm sistem açık koyu @@ -116,7 +114,6 @@ Tor durumu: %1$s, önyükleme %2$d%% Son: %1$s Acil veri silme - İpucu: tüm verileri silmek için uygulama başlığına üç kez dokun (mesajlar, anahtarlar, ayarlar). Hata ayıklama ayarları Open Source • Privacy First • Merkezsiz Kapat @@ -135,7 +132,6 @@ Haritayı aç Yer imini kaldır Teleport - Seçildi Kanaldan ayrıl Nostr üzerinden erişilebilir Favori (çevrimdışı) @@ -264,7 +260,6 @@ bitchat konumunu takip etmez @ · ⧉ - KİŞİLER yakında kimse yok… (sen) geohash seçmek için kaydır/zoom yap diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index df6d1f4f..0f11e124 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -72,7 +72,6 @@ واپس - چینل: %1$s چھوڑیں Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات @@ -102,7 +101,6 @@ geohash پر مبنی چینلز کے ذریعے اپنے قریب کے لوگوں سے جڑیں۔ عوامی انٹرنیٹ ریلے کے ذریعے میش کو پھیلائیں۔ اینڈ‑ٹو‑اینڈ انکرپشن نجی پیغامات انکرپٹڈ ہیں۔ چینل پیغامات عوامی ہیں۔ - ظاہری شکل سسٹم ہلکا گہرا @@ -126,7 +124,6 @@ Tor کی کیفیت: %1$s، بوٹ اسٹریپ %2$d%% آخری: %1$s ہنگامی ڈیٹا صافی - تجویز: تمام محفوظ شدہ ڈیٹا – پیغامات، کلیدیں اور سیٹنگز سمیت – صاف کرنے کے لیے ایپ کے ٹائٹل پر تین بار ٹیپ کریں۔ ڈیبگ سیٹنگز اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کریں @@ -146,7 +143,6 @@ نقشہ کھولیں بک مارک ہٹائیں ٹیلی پورٹ - منتخب چینل چھوڑیں Nostr کے ذریعے رسائی پسندیدہ (آف لائن) @@ -285,7 +281,6 @@ اجازات اجازات دیں bitchat آپ کا مقام ٹریک نہیں کرتا - لوگ یہاں کوئی نہیں… (آپ) geohash منتخب کرنے کے لیے پین/زوم کریں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b5cd2a6e..083a930a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -72,7 +72,6 @@ quay lại - Kênh: %1$s rời khỏi Có thể tiếp cận qua Nostr Tin nhắn riêng chưa đọc @@ -102,7 +101,6 @@ Kết nối với những người trong khu vực của bạn qua các kênh dựa trên geohash. Mở rộng mesh qua các relay Internet công cộng. Mã hóa đầu cuối Tin nhắn riêng được mã hóa. Tin nhắn kênh là công khai. - Giao diện Hệ thống Sáng Tối @@ -126,7 +124,6 @@ Trạng thái Tor: %1$s, Bootstrap %2$d%% Cuối cùng: %1$s Xóa dữ liệu khẩn cấp - Mẹo: Nhấn ba lần vào tiêu đề ứng dụng để xóa tất cả dữ liệu đã lưu – bao gồm tin nhắn, khóa và cài đặt. Cài đặt debug Mã nguồn mở • Quyền riêng tư trước tiên • Phi tập trung Đóng @@ -146,7 +143,6 @@ Mở bản đồ Xóa bookmark Dịch chuyển - Đã chọn Rời khỏi kênh Có thể tiếp cận qua Nostr Yêu thích (ngoại tuyến) @@ -285,7 +281,6 @@ Quyền Cấp quyền bitchat KHÔNG theo dõi vị trí của bạn - MỌI NGƯỜI không có ai xung quanh … (bạn) di chuyển và zoom để chọn geohash diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 05143855..75d92b58 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -72,7 +72,6 @@ 返回 - 频道:%1$s 离开 可通过 Nostr 联系 未读私信 @@ -102,7 +101,6 @@ 使用基于 geohash 的频道与附近的人交流。通过公共互联网中继扩展 mesh。 端到端加密 私信加密;频道消息为公开。 - 外观 系统 浅色 深色 @@ -126,7 +124,6 @@ Tor 状态:%1$s,引导 %2$d%% 最近:%1$s 紧急数据删除 - 提示:三击应用标题以删除所有数据,包括消息、密钥和设置。 调试设置 Open Source • Privacy First • 去中心化 关闭 @@ -146,7 +143,6 @@ 打开地图 移除书签 传送 - 已选择 离开频道 可通过 Nostr 联系 收藏(离线) @@ -280,7 +276,6 @@ bitchat 不会跟踪你的位置 @ · ⧉ - 成员 附近无人… (你) 拖动/缩放以选择 geohash diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e20a4b49..594d6879 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -73,7 +73,6 @@ back - channel: %1$s leave Nostr reachable Unread private messages @@ -131,15 +130,10 @@ Settings - Offline Communication - Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. - Local Channels - Geohash channels connect you with people nearby, extending the mesh over public relays. No Tracking No servers, accounts, or data collection. Ephemeral Identity A new peer ID is generated regularly. - Panic Mode Triple-tap the logo to instantly clear all data. @@ -153,13 +147,12 @@ Connected (%1$d%%) Disconnected - Offline Mesh Chat - Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range. - Online Geohash Channels - Connect with people in your area using geohash-based channels. Extend the mesh using public internet relays. + Offline Communication + Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range. + Local Channels + Geohash channels connect you with people nearby, extending the mesh over public relays. End-to-End Encryption Private messages are encrypted. Channel messages are public. - appearance system light dark @@ -182,8 +175,7 @@ route internet over tor for enhanced privacy. tor Status: %1$s, bootstrap %2$d%% Last: %1$s - Emergency Data Deletion - Tip: Triple-click the app title to emergency delete all stored data including messages, keys, and settings. + Panic Mode Debug Settings Open Source • Privacy First • Decentralized Close @@ -204,7 +196,6 @@ Open map Remove bookmark Teleport - Selected Quit bitchat run in background keep mesh active when app is closed (foreground service) @@ -260,19 +251,15 @@ 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. - Bookmarked Nearby Tor Routing Hides your IP for location channels. Recommended - Remove location access People (%1$d) On location Teleported in - Channels - Mesh grant location permission location permission denied. enable in settings to use location channels. @@ -284,7 +271,7 @@ geohash invalid geohash teleport - disable location services + Remove location access enable location services mesh block @@ -433,7 +420,6 @@ @ Open About · ⧉ - PEOPLE nobody around... (you) pan and zoom to select a geohash diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 44072412..e35e37f1 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -12,7 +12,14 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +/** + * Runs under Robolectric because URL detection in message bodies goes through + * `android.util.Patterns.WEB_URL`, which is null on a bare JVM. + */ +@RunWith(RobolectricTestRunner::class) class ChatUIUtilsTest { private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") @@ -226,7 +233,7 @@ class ChatUIUtilsTest { val sender = formatTextMessageSender( message = message("hi", sender = "carol#04af"), currentUserNickname = "bob", - meshService = FakeMeshServiceIds.stub(), + myPeerID = "peer-me", palette = palette, ) @@ -246,7 +253,7 @@ class ChatUIUtilsTest { val other = formatTextMessageSender( message = message("hi", sender = "carol#04af"), currentUserNickname = "bob", - meshService = FakeMeshServiceIds.stub(), + myPeerID = "peer-me", palette = palette, ) assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size) @@ -254,7 +261,7 @@ class ChatUIUtilsTest { val mine = formatTextMessageSender( message = message("hi", sender = "bob"), currentUserNickname = "bob", - meshService = FakeMeshServiceIds.stub(), + myPeerID = "peer-me", palette = palette, ) assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty()) diff --git a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt new file mode 100644 index 00000000..4ba8ffa0 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt @@ -0,0 +1,152 @@ +package com.bitchat.android.ui + +import androidx.compose.ui.unit.dp +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Grouping decides whether a message repeats its author's name. Getting it wrong either spams + * the transcript with redundant labels or, worse, silently attributes one person's message to + * another — hence the emphasis on the negative cases here. + */ +class MessageGroupingTest { + + private val base = 1_700_000_000_000L + + private fun message( + sender: String = "alice", + senderPeerId: String? = "peer-alice", + offsetMs: Long = 0L, + isPrivate: Boolean = false, + channel: String? = null, + content: String = "hello", + ) = BitchatMessage( + sender = sender, + content = content, + timestamp = Date(base + offsetMs), + isPrivate = isPrivate, + senderPeerID = senderPeerId, + channel = channel, + ) + + @Test + fun `first message in a list never groups`() { + assertFalse(MessageGrouping.shouldGroup(previous = null, current = message())) + } + + @Test + fun `consecutive messages from the same peer group`() { + val first = message(offsetMs = 0) + val second = message(offsetMs = 30_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `messages from different peers do not group`() { + val first = message(sender = "alice", senderPeerId = "peer-alice") + val second = message(sender = "bob", senderPeerId = "peer-bob", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `same nickname but different peer id does not group`() { + // Two people can pick the same nickname; peer ID is the authority. + val first = message(sender = "alice", senderPeerId = "peer-one") + val second = message(sender = "alice", senderPeerId = "peer-two", offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(first, second)) + } + + @Test + fun `falls back to display name when peer ids are unavailable`() { + val first = message(sender = "alice#04af", senderPeerId = null) + val second = message(sender = "alice#04af", senderPeerId = null, offsetMs = 1_000) + val other = message(sender = "bob#1122", senderPeerId = null, offsetMs = 2_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + assertFalse(MessageGrouping.shouldGroup(second, other)) + } + + @Test + fun `messages outside the grouping window start a new group`() { + val first = message(offsetMs = 0) + val justInside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS) + val justOutside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS + 1) + + assertTrue(MessageGrouping.shouldGroup(first, justInside)) + assertFalse(MessageGrouping.shouldGroup(first, justOutside)) + } + + @Test + fun `out of order timestamps do not group`() { + // Mesh delivery can surface messages out of order; a negative delta means we cannot + // reason about adjacency, so fall back to showing the sender. + val later = message(offsetMs = 60_000) + val earlier = message(offsetMs = 0) + + assertFalse(MessageGrouping.shouldGroup(later, earlier)) + } + + @Test + fun `system messages never group in either direction`() { + val system = message(sender = "system", senderPeerId = null, content = "tor started") + val user = message(offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(system, user)) + assertFalse(MessageGrouping.shouldGroup(user, system.copyWithOffset(2_000))) + assertFalse(MessageGrouping.shouldGroup(system, system.copyWithOffset(1_000))) + } + + @Test + fun `grouping does not cross the public private boundary`() { + val public = message(isPrivate = false) + val private = message(isPrivate = true, offsetMs = 1_000) + + assertFalse(MessageGrouping.shouldGroup(public, private)) + } + + @Test + fun `grouping does not cross channels`() { + val inGeneral = message(channel = "#general") + val inRandom = message(channel = "#random", offsetMs = 1_000) + val inNoChannel = message(channel = null, offsetMs = 2_000) + + assertFalse(MessageGrouping.shouldGroup(inGeneral, inRandom)) + assertFalse(MessageGrouping.shouldGroup(inGeneral, inNoChannel)) + } + + @Test + fun `peer id comparison ignores case`() { + val first = message(senderPeerId = "PEER-Alice") + val second = message(senderPeerId = "peer-alice", offsetMs = 1_000) + + assertTrue(MessageGrouping.shouldGroup(first, second)) + } + + // MARK: - Spacing + + @Test + fun `spacing is zero for the very first row`() { + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = true)) + assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = true)) + } + + @Test + fun `grouped rows sit tighter than the start of a new group`() { + val grouped = MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = false) + val newGroup = MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = false) + + assertEquals(MessageGrouping.GROUPED_SPACING, grouped) + assertEquals(MessageGrouping.NEW_GROUP_SPACING, newGroup) + assertTrue("a new speaker needs more air than a continuation", newGroup > grouped) + } + + private fun BitchatMessage.copyWithOffset(offsetMs: Long) = + copy(timestamp = Date(base + offsetMs)) +} From 15d56715b9474ab568d8022f097ca4160e572154 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:51:19 +0200 Subject: [PATCH 03/37] cleanup --- .../ui/component/button/BitChatBrandButton.kt | 6 +- .../core/ui/component/button/CloseButton.kt | 23 ++- .../BackgroundLocationPermissionScreen.kt | 6 +- .../onboarding/BatteryOptimizationScreen.kt | 6 +- .../onboarding/BluetoothCheckScreen.kt | 4 +- .../android/onboarding/LocationCheckScreen.kt | 4 +- .../onboarding/PermissionExplanationScreen.kt | 8 +- .../com/bitchat/android/ui/AboutSections.kt | 56 +++--- .../java/com/bitchat/android/ui/AboutSheet.kt | 39 ++-- .../com/bitchat/android/ui/AnimatedCount.kt | 112 +++++++++++ .../java/com/bitchat/android/ui/ChatHeader.kt | 182 +++++++++-------- .../java/com/bitchat/android/ui/ChatScreen.kt | 5 +- .../bitchat/android/ui/GeohashPeopleList.kt | 16 +- .../android/ui/GeohashPickerActivity.kt | 8 +- .../bitchat/android/ui/GeohashViewModel.kt | 21 +- .../com/bitchat/android/ui/InputComponents.kt | 14 +- .../com/bitchat/android/ui/LinkPreviewPill.kt | 4 +- .../android/ui/LocationChannelsSheet.kt | 32 +-- .../bitchat/android/ui/LocationNotesButton.kt | 8 +- .../bitchat/android/ui/LocationNotesSheet.kt | 4 +- .../android/ui/MatrixEncryptionAnimation.kt | 183 ------------------ .../bitchat/android/ui/MeshPeerListSheet.kt | 103 ++++++---- .../bitchat/android/ui/MessageComponents.kt | 23 +-- .../bitchat/android/ui/PoWStatusIndicator.kt | 133 ------------- .../android/ui/SecurityVerificationSheet.kt | 14 +- .../bitchat/android/ui/VerificationSheet.kt | 4 +- .../android/ui/VoiceInputComponents.kt | 6 +- .../android/ui/debug/DebugSettingsSheet.kt | 16 +- .../com/bitchat/android/ui/debug/MeshGraph.kt | 6 +- .../android/ui/media/AudioMessageItem.kt | 4 +- .../android/ui/media/FileMessageItem.kt | 4 +- .../android/ui/media/FilePickerButton.kt | 4 +- .../android/ui/media/FileSendingAnimation.kt | 4 +- .../android/ui/media/FullScreenImageViewer.kt | 6 +- .../android/ui/media/ImageMessageItem.kt | 4 +- .../android/ui/media/ImagePickerButton.kt | 14 +- .../android/ui/media/MediaPickerOptions.kt | 6 +- .../android/ui/media/VoiceNotePlayer.kt | 6 +- .../android/ui/theme/BitchatPalette.kt | 9 +- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-bn/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-fil/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hi/strings.xml | 1 - app/src/main/res/values-id/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ka/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-mg/strings.xml | 1 - app/src/main/res/values-ne/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pa-rPK/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-sv/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-ur/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh/strings.xml | 1 - app/src/main/res/values/strings.xml | 5 +- 66 files changed, 476 insertions(+), 662 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt delete mode 100644 app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt delete mode 100644 app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index 15b2b40e..c1361364 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bitchat.android.core.ui.icon.BitChatIcon import kotlinx.coroutines.Job @@ -30,6 +31,7 @@ fun BitChatBrandButton( contentDescription: String, modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary, + iconSize: Dp = 22.dp, ) { var tapCount by remember { mutableIntStateOf(0) } var resetJob by remember { mutableStateOf(null) } @@ -63,9 +65,7 @@ fun BitChatBrandButton( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - // 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), + modifier = Modifier.size(iconSize), ) } } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 96f408bc..36ad7526 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -1,34 +1,37 @@ package com.bitchat.android.core.ui.component.button -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.bitchat.android.R +import com.bitchat.android.ui.theme.LocalBitchatPalette @Composable fun CloseButton( onClick: () -> Unit, - modifier: Modifier = Modifier.Companion + modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current IconButton( onClick = onClick, - modifier = modifier - .size(32.dp), + // 44.dp to match every other tap target in the app's chrome. + modifier = modifier.size(44.dp), colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), - containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f) + contentColor = palette.textSecondary, + containerColor = palette.surfaceVariant ) ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Close", - modifier = Modifier.Companion.size(18.dp) + contentDescription = stringResource(R.string.close_plain), + modifier = Modifier.size(18.dp) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 1346cb23..93101abf 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -1,5 +1,8 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Security import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,9 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Security import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ColorScheme diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt index 3fe392d6..0b3036aa 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt @@ -1,13 +1,13 @@ package com.bitchat.android.onboarding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* import androidx.compose.animation.core.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -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 diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index 60c6e5e4..933656bd 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -1,9 +1,9 @@ package com.bitchat.android.onboarding -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt index c5bb8c9b..32c86c0b 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -1,10 +1,10 @@ package com.bitchat.android.onboarding -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index 48138d66..a9b61476 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -1,9 +1,5 @@ package com.bitchat.android.onboarding -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.LocationOn @@ -13,6 +9,10 @@ import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Settings +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index ae1b79a0..3df4fe5d 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -1,5 +1,17 @@ package com.bitchat.android.ui +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.Person +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.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.animateColorAsState @@ -19,18 +31,6 @@ 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 @@ -70,10 +70,12 @@ internal val AboutHorizontalPadding = 20.dp /** Card corner radius for grouped rows. */ internal val AboutCardShape = RoundedCornerShape(16.dp) -/** Two top-level views of the sheet. */ +/** + * Two top-level views of the sheet: what the app is and how to drive it, versus the knobs. + */ enum class AboutTab { - HowToUse, - BasicInfo, + Info, + Settings, } /** @@ -175,7 +177,7 @@ internal fun AboutTabBar( var rowWidth by remember { mutableStateOf(0.dp) } val tabWidth = rowWidth / 2 val indicatorOffset by animateDpAsState( - targetValue = if (selected == AboutTab.HowToUse) 0.dp else tabWidth, + targetValue = if (selected == AboutTab.Info) 0.dp else tabWidth, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "aboutTabIndicator" ) @@ -190,15 +192,15 @@ internal fun AboutTabBar( ) { Row(modifier = Modifier.fillMaxWidth()) { AboutTabLabel( - text = stringResource(R.string.about_tab_how_to_use), - isSelected = selected == AboutTab.HowToUse, - onClick = { onSelect(AboutTab.HowToUse) }, + text = stringResource(R.string.about_tab_info), + isSelected = selected == AboutTab.Info, + onClick = { onSelect(AboutTab.Info) }, modifier = Modifier.weight(1f) ) AboutTabLabel( - text = stringResource(R.string.about_tab_basic_info), - isSelected = selected == AboutTab.BasicInfo, - onClick = { onSelect(AboutTab.BasicInfo) }, + text = stringResource(R.string.about_tab_settings), + isSelected = selected == AboutTab.Settings, + onClick = { onSelect(AboutTab.Settings) }, modifier = Modifier.weight(1f) ) } @@ -272,8 +274,8 @@ private fun AboutInstructionRow( contentDescription = null, tint = colorScheme.primary, modifier = Modifier - .padding(top = 2.dp) - .size(18.dp) + .padding(top = 1.dp) + .size(22.dp) ) Text( text = text, @@ -315,7 +317,7 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { text = stringResource(R.string.about_howto_channels) ) AboutInstructionRow( - icon = Icons.Filled.Groups, + icon = Icons.Filled.Person, text = stringResource(R.string.about_howto_people) ) AboutInstructionRow( @@ -428,7 +430,7 @@ private fun AboutFeatureRow( tint = colorScheme.primary, modifier = Modifier .padding(top = 1.dp) - .size(22.dp) + .size(24.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -580,7 +582,7 @@ internal fun SheetHeaderBadge( imageVector = icon, contentDescription = null, tint = colorScheme.primary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(26.dp) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 2049b518..9b3d6836 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -1,5 +1,12 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Speed import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState @@ -10,13 +17,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.filled.Security -import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -246,7 +246,7 @@ fun AboutSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - var selectedTab by remember { mutableStateOf(AboutTab.HowToUse) } + var selectedTab by remember { mutableStateOf(AboutTab.Info) } if (isPresented) { BitchatBottomSheet( @@ -273,21 +273,22 @@ fun AboutSheet( ) } - if (selectedTab == AboutTab.HowToUse) { + if (selectedTab == AboutTab.Info) { + // What the app is, then how to drive it. Both are reference material a + // new user reads once, so they belong on the same tab. + item(key = "features") { + Column { + AboutSectionLabel(text = stringResource(R.string.about_section_about)) + AboutFeatureCard() + } + } + item(key = "how_to_use") { AboutHowToUseSection() } } - if (selectedTab == AboutTab.BasicInfo) { - // Features Section - Grouped Card - item(key = "features") { - Column { - AboutSectionLabel(text = stringResource(R.string.about_section_about)) - AboutFeatureCard() - } - } - + if (selectedTab == AboutTab.Settings) { // Appearance Section item(key = "appearance") { Column { @@ -569,7 +570,7 @@ fun AboutSheet( item(key = "warning") { AboutWarningCard(modifier = Modifier.padding(top = 24.dp)) } - } // end BasicInfo tab + } // end Settings tab // Footer item(key = "footer") { diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt new file mode 100644 index 00000000..41650b25 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt @@ -0,0 +1,112 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import com.bitchat.android.ui.theme.BitchatMotion + +/** + * Counts that roll to their new value instead of snapping. + * + * Peer counts change on their own, without the user doing anything, so a hard digit swap is easy + * to miss and looks like a rendering fault when it is noticed. Sliding the digits in the + * direction the number moved — up when someone joins, down when someone leaves — conveys the + * change without needing a separate indicator. + */ +@Composable +fun AnimatedCount( + count: Int, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + prefix: String = "", +) { + AnimatedContent( + targetState = count, + transitionSpec = { + val goingUp = targetState > initialState + ( + slideInVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + initialOffsetY = { height -> if (goingUp) height else -height } + ) + fadeIn(tween(BitchatMotion.STANDARD_MS)) + ).togetherWith( + slideOutVertically( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + targetOffsetY = { height -> if (goingUp) -height else height } + ) + fadeOut(tween(BitchatMotion.QUICK_MS)) + // Clip so the outgoing digit cannot bleed past the text bounds mid-transition. + ) using SizeTransform(clip = true) + }, + modifier = modifier, + label = "animatedCount" + ) { value -> + Text( + text = "$prefix$value", + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} + +/** + * Cross-fades a label whose text embeds a count, e.g. `People (7)` or `3 people`. + * + * Used where the number is not isolated in its own composable and cannot be rolled on its own. + * The transition is keyed on [count] rather than on [text] so that a label changing for some + * other reason (a locale switch, say) does not animate. + */ +@Composable +fun AnimatedCountLabel( + count: Int, + text: String, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, +) { + AnimatedContent( + targetState = count, + transitionSpec = { + fadeIn(tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)) + .togetherWith(fadeOut(tween(BitchatMotion.QUICK_MS))) + .using(SizeTransform(clip = false)) + }, + modifier = modifier, + label = "animatedCountLabel" + ) { _ -> + Text( + text = text, + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index adda2378..6576a712 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,10 +1,20 @@ package com.bitchat.android.ui +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 android.util.Log +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* @@ -14,10 +24,6 @@ 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 @@ -45,14 +51,29 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette * 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 +/** Height of the chat top bar. Taller than the old 42.dp so 44.dp tap targets fit properly. */ +val ChatHeaderHeight = 52.dp -/** Standard icon size in the header. */ -private val HeaderIconSize = 18.dp +/** + * The single icon size used by every glyph in the top bar. + * + * Previously the bar mixed 12/14/16/18.dp icons, which is why it read as a collection of + * unrelated glyphs rather than one control strip. One size for everything, and large enough to + * actually see. + */ +internal val HeaderIconSize = 22.dp + +/** + * Text size for the top bar's labels: nickname, channel name, peer count. + * + * A step up from the 15.sp body scale. The bar is the app's primary status readout and was + * noticeably harder to read than the messages below it; the extra point costs nothing because + * the bar's height is driven by [HeaderTapTarget], not by the text. + */ +private val HeaderTextSize = 17.sp /** Minimum tap target for every interactive element in the header. */ -private val HeaderTapTarget = 40.dp +private val HeaderTapTarget = 44.dp /** Corner radius for the header's tappable label+icon clusters. */ private val HeaderClusterShape = RoundedCornerShape(8.dp) @@ -81,6 +102,14 @@ private fun HeaderIconButton( } } +/** + * Connection status indicator. + * + * Only visible while the connection is still coming up (or has failed). A steady green "all + * good" light is noise: the absence of the indicator already means everything is fine, and the + * eye stops registering an always-on dot anyway. It fades and scales in/out so appearing and + * disappearing reads as a state change rather than a glitch. + */ @Composable fun TorStatusDot( modifier: Modifier = Modifier @@ -88,27 +117,35 @@ fun TorStatusDot( 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 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 isEnabled = torStatus.mode != com.bitchat.android.net.TorMode.OFF + val isEstablished = torStatus.running && torStatus.bootstrapPercent >= 100 + val isVisible = isEnabled && !isEstablished + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.4f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.4f, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ), + ) { + // Bootstrapping vs. failed. Cross-faded, because Tor flips between these frequently and + // a hard colour cut in the corner of the screen reads as a rendering fault. val dotColor by animateColorAsState( - targetValue = targetColor, + targetValue = if (torStatus.running) palette.accentOrange else palette.accentRed, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "torDotColor" ) - Canvas( - modifier = modifier - ) { - val radius = size.minDimension / 2 + Canvas(modifier = modifier) { drawCircle( color = dotColor, - radius = radius, + radius = size.minDimension / 2, center = Offset(size.width / 2, size.height / 2) ) } @@ -179,15 +216,17 @@ fun NicknameEditor( Text( text = stringResource(R.string.at_symbol), style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, color = colorScheme.primary.copy(alpha = 0.7f) ) - + BasicTextField( value = value, onValueChange = onValueChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, + fontSize = HeaderTextSize ), cursorBrush = SolidColor(colorScheme.primary), singleLine = true, @@ -198,7 +237,7 @@ fun NicknameEditor( } ), modifier = Modifier - .widthIn(max = 140.dp) + .widthIn(max = 150.dp) .horizontalScroll(scrollState) ) } @@ -250,9 +289,9 @@ fun PeerCounter( .padding(horizontal = 6.dp) ) { Icon( - // `Groups` reads as a crowd at 18.dp, where `Group`'s two-person silhouette turns - // into an indistinct blob. - imageVector = Icons.Filled.Groups, + // A single silhouette rather than a crowd: at 22.dp a multi-person glyph collapses + // into an indistinct blob, and the number beside it already conveys "how many". + imageVector = Icons.Filled.Person, contentDescription = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants) else -> stringResource(R.string.cd_connected_peers) @@ -261,17 +300,20 @@ fun PeerCounter( tint = animatedCountColor ) - Text( - text = "$peopleCount", + AnimatedCount( + count = peopleCount, style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, color = animatedCountColor, fontWeight = FontWeight.Medium ) - + if (joinedChannels.isNotEmpty()) { - Text( - text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}", + AnimatedCount( + count = joinedChannels.size, + prefix = stringResource(R.string.channel_count_prefix), style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, color = if (isConnected) palette.accentGreen else palette.accentRed, fontWeight = FontWeight.Medium ) @@ -343,7 +385,7 @@ private fun ChannelHeader( Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(HeaderIconSize), tint = colorScheme.primary ) } @@ -352,6 +394,7 @@ private fun ChannelHeader( Text( text = "#$channel", style = MaterialTheme.typography.titleMedium, + fontSize = HeaderTextSize, color = colorScheme.primary, modifier = Modifier .align(Alignment.Center) @@ -368,6 +411,7 @@ private fun ChannelHeader( Text( text = stringResource(R.string.chat_leave), style = MaterialTheme.typography.labelMedium, + fontSize = 15.sp, color = palette.accentRed ) } @@ -395,11 +439,6 @@ private fun MainHeader( val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() - // Bookmarks store for current geohash toggle (iOS parity) - val context = androidx.compose.ui.platform.LocalContext.current - val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } - val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() - Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -423,6 +462,7 @@ private fun MainHeader( Text( text = "/", style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, // 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), @@ -435,12 +475,26 @@ private fun MainHeader( ) } - // MARK: - Status cluster. Order matches the design: bookmark, channel, people. + // MARK: - Status cluster. + // + // Order, left to right: connection state, unread DMs, notes, channel, people. + // The connection indicator leads because it is the only item that can invalidate + // everything to its right, and because it appears and disappears on its own — a fixed + // leftmost slot means the rest of the cluster never shifts when it does. Row( verticalAlignment = Alignment.CenterVertically, - // Tight, because every child below is its own >=40.dp tap target. + // Tight, because every child below is its own >=44.dp tap target. horizontalArrangement = Arrangement.spacedBy(2.dp) ) { + // Connection status. The Box holds the slot open at a constant width so the icons to + // its right stay put whether or not the dot is currently showing. + Box( + modifier = Modifier.size(HeaderIconSize), + contentAlignment = Alignment.Center + ) { + TorStatusDot(modifier = Modifier.size(8.dp)) + } + // Unread private messages badge (click to open most recent DM) if (hasUnreadPrivateMessages.isNotEmpty()) { HeaderIconButton( @@ -462,37 +516,9 @@ private fun MainHeader( onClick = onLocationNotesClick ) - // 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 - ) - - // 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) - ) - } - } + // Bookmarking lives in the Location Channels sheet, one tap away via the channel + // button below. Duplicating it here bought a shortcut for a rare action at the cost + // of a slot in the app's most crowded row. LocationChannelsButton( viewModel = viewModel, @@ -532,8 +558,11 @@ private fun LocationChannelsButton( val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location val badgeText = when (val channel = selectedChannel) { + // Geohashes keep the '#' because that is how they are written and typed everywhere else. is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}" - else -> "#mesh" + // The local mesh is not a hashtag channel, and the Bluetooth glyph already says what it + // is, so it is plain "mesh". + else -> stringResource(R.string.mesh_label) } val badgeColor = if (isLocation) palette.accentGreen else palette.accentBlue val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth @@ -557,6 +586,7 @@ private fun LocationChannelsButton( Text( text = badgeText, style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, fontWeight = FontWeight.Medium, color = badgeColor, maxLines = 1 @@ -567,7 +597,7 @@ private fun LocationChannelsButton( Icon( imageVector = Icons.Default.PinDrop, contentDescription = stringResource(R.string.cd_teleported), - modifier = Modifier.size(12.dp), + modifier = Modifier.size(HeaderIconSize), tint = badgeColor ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 340f180c..6f4ff8a3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -4,6 +4,8 @@ package com.bitchat.android.ui // [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.animation.* import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween @@ -14,8 +16,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButton @@ -303,6 +303,7 @@ fun ChatScreen(viewModel: ChatViewModel) { Icon( imageVector = Icons.Filled.ArrowDownward, contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), + modifier = Modifier.size(22.dp), tint = palette.accentGreen ) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 1eccc152..cb3cc476 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,13 +1,13 @@ 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 import androidx.compose.material.icons.outlined.LocationOn +import android.util.Log +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -193,7 +193,7 @@ private fun GeohashPersonItem( Icon( imageVector = Icons.Filled.Email, contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(16.dp), + modifier = Modifier.size(18.dp), tint = palette.accentOrange ) } else { @@ -205,7 +205,7 @@ private fun GeohashPersonItem( else -> "face.smiling" to palette.textSecondary } - // Use appropriate Material icon (closest match to iOS SF Symbols) + // Teleported users get the pin (they chose this location); locals get the person. val icon = when (iconName) { "face.dashed" -> Icons.Outlined.Explore else -> Icons.Outlined.LocationOn @@ -214,8 +214,8 @@ private fun GeohashPersonItem( Icon( imageVector = icon, contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(16.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent + modifier = Modifier.size(18.dp), + tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index e15f9930..0f2f53bb 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -1,5 +1,9 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Remove import android.annotation.SuppressLint import android.app.Activity import android.content.Intent @@ -15,10 +19,6 @@ import android.webkit.WebViewClient import androidx.activity.compose.setContent import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 0504aa11..4cae30dd 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -210,22 +210,11 @@ class GeohashViewModel( powDifficulty = if (pow.enabled) pow.difficulty else null ) messageManager.addChannelMessage("geo:${channel.geohash}", localMsg) - val startedMining = pow.enabled && pow.difficulty > 0 - if (startedMining) { - com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId) - } - try { - val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) - val teleported = state.isTeleported.value - val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) - val relayManager = NostrRelayManager.getInstance(getApplication()) - relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) - } finally { - // Ensure we stop the per-message mining animation regardless of success/failure - if (startedMining) { - com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempId) - } - } + val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) + val teleported = state.isTeleported.value + val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) + val relayManager = NostrRelayManager.getInstance(getApplication()) + relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 3fac4761..938729ce 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -2,6 +2,8 @@ package com.bitchat.android.ui // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween @@ -12,8 +14,6 @@ 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.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -187,10 +187,10 @@ private val ComposerMinHeight = 44.dp private val ComposerShape = RoundedCornerShape(22.dp) /** Diameter of the circular send affordance nested inside the pill. */ -private val SendButtonSize = 32.dp +private val SendButtonSize = 36.dp -/** Tap target for the media affordances sitting outside the pill. */ -private val MediaButtonSize = 40.dp +/** Icon size shared by the composer's glyphs, matching the top bar. */ +internal val ComposerIconSize = 22.dp @Composable fun MessageInput( @@ -325,7 +325,7 @@ fun MessageInput( hasText = hasText, isAccented = selectedPrivatePeer != null || currentChannel != null, onSend = onSend, - modifier = Modifier.padding(end = 6.dp, bottom = 6.dp) + modifier = Modifier.padding(end = 4.dp, bottom = 4.dp) ) } @@ -430,7 +430,7 @@ private fun SendButton( Icon( imageVector = Icons.Filled.ArrowUpward, contentDescription = stringResource(id = R.string.send_message), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(ComposerIconSize), tint = tint ) } diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 4896e345..cc0d3026 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Link import android.content.Intent import android.net.Uri import androidx.compose.foundation.background @@ -7,8 +9,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Link import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 98e1fb40..3f3e59cf 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,5 +1,12 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +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 android.content.Intent import android.net.Uri import android.provider.Settings @@ -15,13 +22,6 @@ 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 -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 @@ -218,6 +218,7 @@ fun LocationChannelsSheet( title = meshTitleWithCount(viewModel), subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()), isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), titleColor = standardBlue, titleBold = meshCount(viewModel) > 0, trailingContent = null, @@ -251,6 +252,7 @@ fun LocationChannelsSheet( title = geohashTitleWithCount(channel, participantCount), subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, titleColor = standardGreen, titleBold = highlight, trailingContent = { @@ -259,7 +261,7 @@ fun LocationChannelsSheet( 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) + modifier = Modifier.size(22.dp) ) } }, @@ -314,6 +316,7 @@ fun LocationChannelsSheet( title = title, subtitle = subtitle, isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, titleColor = null, titleBold = participantCount > 0, trailingContent = { @@ -322,7 +325,7 @@ fun LocationChannelsSheet( imageVector = Icons.Filled.Bookmark, contentDescription = stringResource(R.string.cd_remove_bookmark), tint = palette.accentGreen, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(22.dp) ) } }, @@ -432,7 +435,7 @@ fun LocationChannelsSheet( imageVector = Icons.Filled.Map, contentDescription = stringResource(R.string.cd_open_map), tint = palette.textSecondary, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(22.dp) ) } @@ -468,7 +471,7 @@ fun LocationChannelsSheet( Icon( imageVector = Icons.Filled.PinDrop, contentDescription = stringResource(R.string.cd_teleport), - modifier = Modifier.size(12.dp), + modifier = Modifier.size(14.dp), tint = teleportColor ) Text( @@ -641,6 +644,8 @@ private fun ChannelRow( title: String, subtitle: String, isSelected: Boolean, + /** Drives the count animation; the number itself is already baked into [title]. */ + participantCount: Int, titleColor: Color? = null, titleBold: Boolean = false, trailingContent: (@Composable (() -> Unit))? = null, @@ -701,7 +706,10 @@ private fun ChannelRow( ) countSuffix?.let { count -> - Text( + AnimatedCountLabel( + // Participant counts are polled from the relays, so they change + // while the sheet is open. + count = participantCount, text = count, fontSize = 11.sp, fontFamily = FontFamily.Monospace, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index af1198bc..1ec6417e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -1,8 +1,8 @@ package com.bitchat.android.ui -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description +import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -55,12 +55,12 @@ fun LocationNotesButton( val hasNotes = notesCount > 0 IconButton( onClick = onClick, - modifier = modifier.size(40.dp) + modifier = modifier.size(44.dp) ) { Icon( - imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent + imageVector = Icons.Outlined.Description, contentDescription = stringResource(R.string.cd_location_notes), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(HeaderIconSize), tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary ) } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 29ead6a6..4543d2af 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -9,8 +11,6 @@ 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.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.getValue diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt deleted file mode 100644 index 52632f93..00000000 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.bitchat.android.ui - -import androidx.compose.material3.ColorScheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.mesh.MeshService -import com.bitchat.android.model.BitchatMessage -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import kotlin.random.Random - -/** - * Animation state for individual characters - */ -private enum class CharacterAnimationState { - ENCRYPTED, // Showing random encrypted characters - FINAL // Showing final decrypted character -} - -/** - * Check if a message should be animated based on its mining state - */ -@Composable -fun shouldAnimateMessage(messageId: String): Boolean { - val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle() - return miningMessages.contains(messageId) -} - -/** - * Tracks which messages are currently being mined for PoW - * Provides reactive state for UI animations - */ -object PoWMiningTracker { - private val _miningMessages = MutableStateFlow>(emptySet()) - val miningMessages: StateFlow> = _miningMessages.asStateFlow() - - /** - * Start tracking a message as mining - */ - fun startMiningMessage(messageId: String) { - _miningMessages.value = _miningMessages.value + messageId - } - - /** - * Stop tracking a message as mining - */ - fun stopMiningMessage(messageId: String) { - _miningMessages.value = _miningMessages.value - messageId - } - - /** - * Check if a message is currently mining - */ - fun isMiningMessage(messageId: String): Boolean { - return _miningMessages.value.contains(messageId) - } - - /** - * Clear all mining messages (for cleanup) - */ - fun clearAllMining() { - _miningMessages.value = emptySet() - } -} - -/** - * Shows the active PoW animation inside the same two-row layout used by static text messages. - */ -@Composable -fun MessageWithMatrixAnimation( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat, - onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier, - showSender: Boolean = true, -) { - AnimatedMessageDisplay( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier, - showSender = showSender, - ) -} - -/** - * Animates only the body content; sender, metadata, gestures, and spacing remain stable. - */ -@Composable -private fun AnimatedMessageDisplay( - message: BitchatMessage, - currentUserNickname: String, - meshService: MeshService, - colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat, - onNicknameClick: ((String) -> Unit)?, - onMessageLongPress: ((BitchatMessage) -> Unit)?, - modifier: Modifier = Modifier, - showSender: Boolean = true, -) { - var animatedContent by remember(message.id, message.content) { - mutableStateOf(message.content) - } - - // Character-by-character animation state like the JavaScript version - var characterStates by remember(message.id, message.content) { - mutableStateOf(message.content.map { char -> - if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED - }) - } - - LaunchedEffect(message.id, message.content) { - if (message.content.isEmpty()) return@LaunchedEffect - - val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() - - // Start character animations with staggered delays (like JS version). - message.content.forEachIndexed { index, targetChar -> - if (targetChar != ' ') { - launch { - delay(index * 50L) - - while (true) { - while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { - val newContent = animatedContent.toCharArray() - if (index < newContent.size) { - newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)] - animatedContent = String(newContent) - } - - delay(100L) - - if (Random.nextFloat() < 0.1f) { - val finalContent = animatedContent.toCharArray() - if (index < finalContent.size) { - finalContent[index] = targetChar - animatedContent = String(finalContent) - } - - val finalStates = characterStates.toMutableList() - finalStates[index] = CharacterAnimationState.FINAL - characterStates = finalStates - break - } - } - - delay(2000L) - - val resetStates = characterStates.toMutableList() - resetStates[index] = CharacterAnimationState.ENCRYPTED - characterStates = resetStates - } - } - } - } - } - - TextMessageLayout( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier, - showSender = showSender, - bodyContent = animatedContent, - ) -} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index a6c0d63d..5aae35a3 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1,19 +1,25 @@ package com.bitchat.android.ui +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 com.bitchat.android.R import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.* 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.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.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -37,6 +43,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.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry @@ -116,8 +123,9 @@ fun MeshPeerListSheet( .padding(horizontal = SheetHorizontalPadding + 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - SheetHeaderBadge(icon = Icons.Filled.Groups) - Text( + SheetHeaderBadge(icon = Icons.Filled.Person) + AnimatedCountLabel( + count = peopleCount, text = stringResource(R.string.people_count_title, peopleCount), fontSize = 20.sp, fontFamily = FontFamily.Monospace, @@ -212,13 +220,13 @@ fun MeshPeerListSheet( if (selectedLocationChannel !is ChannelID.Location) { IconButton( onClick = onShowVerification, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(44.dp) ) { Icon( imageVector = Icons.Outlined.QrCode, contentDescription = stringResource(R.string.verify_title), - tint = colorScheme.onSurface.copy(alpha = 0.8f), - modifier = Modifier.size(18.dp) + tint = LocalBitchatPalette.current.textSecondary, + modifier = Modifier.size(22.dp) ) } } @@ -231,6 +239,9 @@ fun MeshPeerListSheet( } } +/** Icon size for rows inside the sheets: one step down from the top bar. */ +private val PeerRowIconSize = 18.dp + @Composable private fun ChannelRow( channel: String, @@ -580,28 +591,26 @@ private fun PeerItem( ) { // Connection/status indicator if (hasUnreadDM) { - // Show mail icon for unread DMs (iOS orange) Icon( imageVector = Icons.Filled.Email, contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // iOS orange + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentOrange ) } else if (showNostrGlobe) { - // Purple globe to indicate Nostr availability Icon( imageVector = Icons.Filled.Public, contentDescription = stringResource(R.string.cd_reachable_via_nostr), - modifier = Modifier.size(16.dp), - tint = Color(0xFF9C27B0) // Purple + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentPurple ) } else if (!isDirect && isFavorite) { - // Offline favorited user: show outlined circle icon + // Offline favourite: routed glyph, dimmed to read as unavailable. Icon( imageVector = Icons.Outlined.Circle, contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(16.dp), - tint = Color.Gray + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textTertiary ) } else { Icon( @@ -615,8 +624,8 @@ private fun PeerItem( isDirect -> "Direct Bluetooth" else -> "Routed" }, - modifier = Modifier.size(16.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textSecondary ) } @@ -649,8 +658,8 @@ private fun PeerItem( Icon( imageVector = Icons.Filled.Wifi, contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(13.dp), - tint = colorScheme.onSurface.copy(alpha = 0.8f) + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textSecondary ) } } @@ -660,9 +669,9 @@ private fun PeerItem( Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = Icons.Filled.Verified, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = Color(0xFF32D74B) // iOS Green + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentGreen ) } @@ -673,13 +682,13 @@ private fun PeerItem( // Favorite star with proper filled/outlined states IconButton( onClick = onToggleFavorite, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(44.dp) ) { Icon( imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite) palette.accentOrange else palette.textTertiary ) } } @@ -696,25 +705,41 @@ private fun UnreadBadge( colorScheme: ColorScheme, modifier: Modifier = Modifier ) { - if (count > 0) { + val palette = LocalBitchatPalette.current + // Scale/fade in and out so a badge appearing while the sheet is open is noticed, and one + // clearing does not just blink away. + AnimatedVisibility( + visible = count > 0, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.5f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.5f, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ), + modifier = modifier + ) { Box( - modifier = modifier + modifier = Modifier .background( - color = Color(0xFFFFD700), // Yellow color + color = palette.accentOrange, shape = RoundedCornerShape(10.dp) ) .padding(horizontal = 6.dp, vertical = 2.dp) .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp), contentAlignment = Alignment.Center ) { - Text( + AnimatedCountLabel( + count = count, text = if (count > 99) "99+" else count.toString(), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = Color.Black // Black text on yellow background + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + color = Color.Black ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 7457c681..83db678a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,6 +1,8 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -17,8 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -393,24 +393,7 @@ fun MessageItem( return } - // Check if this message should be animated during PoW mining - val shouldAnimate = shouldAnimateMessage(message.id) - - // If animation is needed, use the matrix animation component for content only - if (shouldAnimate) { - // Display message with matrix animation for content - MessageWithMatrixAnimation( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - showSender = showSender, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier - ) - } else if (message.sender == "system") { + if (message.sender == "system") { // Background narration: `// Tor started. Routing all chats…` val annotatedText = remember(message, palette) { formatSystemMessage( diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt deleted file mode 100644 index 883b2b45..00000000 --- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.bitchat.android.ui - -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Security -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -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.nostr.NostrProofOfWork -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.bitchat.android.R -import com.bitchat.android.nostr.PoWPreferenceManager - -/** - * Shows the current Proof of Work status and settings - */ -@Composable -fun PoWStatusIndicator( - modifier: Modifier = Modifier, - style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT -) { - val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle() - val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle() - val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - if (!powEnabled) return - - when (style) { - PoWIndicatorStyle.COMPACT -> { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // PoW icon with animation if mining - if (isMining) { - val rotation by rememberInfiniteTransition(label = "pow-rotation").animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "pow-icon-rotation" - ) - - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_mining_pow), - tint = Color(0xFFFF9500), // Orange for mining - modifier = Modifier - .size(12.dp) - .graphicsLayer { rotationZ = rotation } - ) - } else { - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_pow_enabled), - tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready - modifier = Modifier.size(12.dp) - ) - } - } - } - - PoWIndicatorStyle.DETAILED -> { - Surface( - modifier = modifier, - color = colorScheme.surfaceVariant.copy(alpha = 0.3f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // PoW icon - Icon( - imageVector = Icons.Filled.Security, - contentDescription = stringResource(R.string.cd_proof_of_work), - tint = if (isMining) Color(0xFFFF9500) else { - if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - }, - modifier = Modifier.size(14.dp) - ) - - // Status text - Text( - text = if (isMining) { - stringResource(R.string.pow_mining_ellipsis) - } else { - stringResource(R.string.pow_label_format, powDifficulty) - }, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = if (isMining) Color(0xFFFF9500) else { - colorScheme.onSurface.copy(alpha = 0.7f) - } - ) - - // Time estimate - if (!isMining && powDifficulty > 0) { - Text( - text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)), - fontSize = 9.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) - ) - } - } - } - } - } -} - -/** - * Style options for the PoW status indicator - */ -enum class PoWIndicatorStyle { - COMPACT, // Small icon + difficulty number - DETAILED // Icon + status text + time estimate -} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 2633f888..5549f43f 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -1,5 +1,12 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.NoEncryption +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -12,13 +19,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Verified -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.outlined.NoEncryption -import androidx.compose.material.icons.outlined.Sync -import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 97392eae..a81938d5 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCodeScanner import android.graphics.Bitmap import android.os.Handler import android.os.Looper @@ -31,8 +33,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index 1c005b46..cdc40583 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -1,13 +1,13 @@ package com.bitchat.android.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic import android.Manifest import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures 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.Mic import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -132,7 +132,7 @@ fun VoiceRecordButton( imageVector = Icons.Filled.Mic, contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice), tint = Color.Black, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(22.dp) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index be950316..8d38c678 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -1,5 +1,13 @@ package com.bitchat.android.ui.debug +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiTethering +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -9,14 +17,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.WifiTethering -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.PowerSettingsNew -import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt index 44c7ef55..3254c565 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt @@ -1,14 +1,14 @@ package com.bitchat.android.ui.debug +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.outlined.Bluetooth import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index dc52f845..8722ab29 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -1,11 +1,11 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt index ca57931b..7a289686 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,8 +14,6 @@ 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.Description import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon diff --git a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt index 16d92d7a..bd5f30e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt @@ -1,11 +1,11 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Attachment import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Attachment import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt index 2f40dec0..2ec7cd9a 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt @@ -1,5 +1,7 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -11,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt index 2964f9c0..bebc1293 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download import android.content.ContentValues import android.os.Build import android.provider.MediaStore @@ -10,9 +13,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 01d119fe..a0961cfc 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -1,12 +1,12 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.* diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index 172ad50d..df6f8ea7 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -1,5 +1,9 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PhotoCamera import android.Manifest import android.content.pm.PackageManager import android.net.Uri @@ -10,10 +14,6 @@ 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 -import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material3.Icon import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -91,8 +91,8 @@ fun ImagePickerButton( Box( modifier = modifier - // 40.dp to match the header's tap targets and the composer's send button. - .size(40.dp) + // Matches the header's tap targets and the composer's send button. + .size(44.dp) .clip(CircleShape) .combinedClickable( onClick = { imagePicker.launch("image/*") }, @@ -110,7 +110,7 @@ fun ImagePickerButton( imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), tint = LocalBitchatPalette.current.textSecondary, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(22.dp) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt index 47883499..6909a52d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt @@ -1,5 +1,8 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Description import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -9,9 +12,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 67719d87..5341ec1f 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -1,13 +1,13 @@ package com.bitchat.android.ui.media +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import android.media.MediaPlayer import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index bd4280eb..bc5e7b03 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -22,7 +22,7 @@ data class BitchatPalette( val isDark: Boolean, // MARK: - Surfaces - /** Screen, header and composer background. Slightly green-tinted near-black. */ + /** Screen, header and composer background. True black in dark mode. */ val background: Color, /** Sheets, cards and list rows: one step above [background]. */ val surface: Color, @@ -56,7 +56,10 @@ data class BitchatPalette( val DarkBitchatPalette = BitchatPalette( isDark = true, - background = Color(0xFF070D07), + // True black. OLED panels switch these pixels off entirely, which is both the terminal + // aesthetic the app is going for and a real battery win on a screen that is mostly + // background. + background = Color(0xFF000000), surface = Color(0xFF0E150E), surfaceVariant = Color(0xFF182118), outline = Color(0xFF2A3A2A), @@ -73,7 +76,7 @@ val DarkBitchatPalette = BitchatPalette( val LightBitchatPalette = BitchatPalette( isDark = false, - background = Color(0xFFFBFDFB), + background = Color(0xFFFFFFFF), surface = Color(0xFFF2F6F2), surfaceVariant = Color(0xFFE7EDE7), outline = Color(0xFFCBD6CB), diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5ad12ff2..00892344 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -75,7 +75,6 @@ مغادرة يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة - تبديل الإشارة المرجعية تم الانتقال حالة Tor الأقران المتصلون diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 76c13dd2..45750704 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -75,7 +75,6 @@ ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা - বুকমার্ক টগল করুন টেলিপোর্ট করা হয়েছে Tor অবস্থা সংযুক্ত পিয়ার diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 23168837..dd2675fe 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -75,7 +75,6 @@ verlassen Über Nostr erreichbar Ungelesene private Nachrichten - Lesezeichen umschalten Teleportiert Tor‑Status Verbundenen Peers diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 836f563a..76871eeb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -75,7 +75,6 @@ salir Accesible vía Nostr Mensajes privados sin leer - Alternar marcador Teletransportado Estado de Tor Pares conectados diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 551ca8db..0bd3529f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -75,7 +75,6 @@ خروج قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده - تغییر وضعیت نشانک انتقال مکانی وضعیت Tor همتایان متصل diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 526cc636..288068bc 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -75,7 +75,6 @@ umalis Maabot sa Nostr Hindi nabasang pribadong mensahe - I‑toggle ang bookmark Naiteleport Katayuan ng Tor Nakakonektang peers diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8da0188f..4d2a0d52 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -75,7 +75,6 @@ quitter Joignable via Nostr Messages privés non lus - Basculer le signet Téléporté Statut Tor Pairs connectés diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 6921ace7..5074fa44 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -75,7 +75,6 @@ छोड़ें Nostr के माध्यम से उपलब्ध अपठित निजी संदेश - बुकमार्क टॉगल करें टेलीपोर्ट हुआ Tor स्थिति कनेक्टेड पीयर्स diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index c5eb612c..cae8b84a 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -75,7 +75,6 @@ keluar Dapat dijangkau melalui Nostr Pesan pribadi yang belum dibaca - Alihkan bookmark Diteleportasi Status Tor Peer yang terhubung diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8c157300..78cc8b84 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -75,7 +75,6 @@ esci Raggiungibile via Nostr Messaggi privati non letti - Attiva/disattiva segnalibro Teletrasportato Stato Tor Peer connessi diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b272e51f..c5b7a5c6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -75,7 +75,6 @@ 退出 Nostr 経由で到達可能 未読のプライベートメッセージ - ブックマークを切り替え テレポート済み Tor ステータス 接続中のピア diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 65f27848..2c682575 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -75,7 +75,6 @@ გასვლა მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები - სანიშნეს გადართვა ტელეპორტირებული Tor-ის სტატუსი დაკავშირებული პირები diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ee2838b6..4cda1af6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -75,7 +75,6 @@ 나가기 Nostr로 연결 가능 읽지 않은 개인 메시지 - 북마크 전환 텔레포트됨 Tor 상태 연결된 피어 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index ffec5957..6cff0fe3 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -75,7 +75,6 @@ hiala Azo tratrarina amin\'ny alalan\'ny Nostr Hafatra manokana tsy voavaky - Hanova bookmark Nafindra toerana Toe-javatra Tor Mpiara-miasa mifandray diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 7c2bbf26..f80354db 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -75,7 +75,6 @@ छोड्नुहोस् Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश - बुकमार्क टगल टेलिपोर्ट भयो Tor स्थिति जडान भएका साथीहरू diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d9de3a6d..7ce6dbc9 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -75,7 +75,6 @@ verlaten Bereikbaar via Nostr Ongelezen privéberichten - Bladwijzer omschakelen Geteleporteerd Tor-status Verbonden peers diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index f7423ce5..46024ab5 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -75,7 +75,6 @@ باہر آؤ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام - بُک مارک ٹوگل کرو ٹیلپورٹ کیتا گیا Tor حالت جُڑے پیئر diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a350d3f7..96043e4f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -75,7 +75,6 @@ sair Acessível via Nostr Mensagens privadas não lidas - Alternar favorito Teletransportado Status do Tor Peers conectados diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 9751b733..184e4b2a 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -75,7 +75,6 @@ sair Alcançável via Nostr Mensagens privadas não lidas - Alternar marcador Teletransportado Estado do Tor Pares ligados diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5672cd00..4a60c472 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -67,7 +67,6 @@ выйти Доступен через Nostr Непрочитанные личные - Переключить закладку Телепортировано Статус Tor Подключённые узлы diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index a6df7a8e..9eabab8a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -67,7 +67,6 @@ lämna Nås via Nostr Olästa privata meddelanden - Växla bokmärke Teleporterad Tor‑status Anslutna noder diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index af9111fa..ddb21b5e 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -75,7 +75,6 @@ ออก ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน - สลับที่คั่นหน้า เทเลพอร์ตแล้ว สถานะ Tor เพียร์ที่เชื่อมต่อ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 721b50c1..7d64afdf 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -67,7 +67,6 @@ ayrıl Nostr üzerinden ulaşılabilir Okunmamış özel mesaj - Yer imini değiştir Teleport edildi Tor durumu Bağlı eşler diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 0f11e124..60fc378f 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -75,7 +75,6 @@ چھوڑیں Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات - بک مارک تبدیل کریں ٹیلی پورٹ Tor کی کیفیت منسلک پیئرز diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 083a930a..7858dd49 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -75,7 +75,6 @@ rời khỏi Có thể tiếp cận qua Nostr Tin nhắn riêng chưa đọc - Bật/tắt bookmark Đã dịch chuyển Trạng thái Tor Peers đã kết nối diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 75d92b58..21ce5d51 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -75,7 +75,6 @@ 离开 可通过 Nostr 联系 未读私信 - 切换书签 已传送 Tor 状态 已连接节点 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 594d6879..0818a9f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,7 +76,6 @@ leave Nostr reachable Unread private messages - Toggle bookmark Location notes Teleported Tor status @@ -111,8 +110,8 @@ The sidegroup chat - How to use - Basic info + Info + Settings How To Use From fc1c23314bd3cd758e7204ee2dc728a3ecb2f67c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:57:22 +0200 Subject: [PATCH 04/37] capitalization --- .../bitchat/android/ui/MeshPeerListSheet.kt | 2 +- .../android/ui/debug/DebugSettingsSheet.kt | 12 +- app/src/main/res/values/strings.xml | 286 +++++++++--------- 3 files changed, 150 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 5aae35a3..180b35c8 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -829,7 +829,7 @@ fun PrivateChatSheet( val name = if (fullPubkey.isNotEmpty()) { viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh) } else { - peerNicknames[peerID] ?: "unknown" + peerNicknames[peerID] ?: "Unknown" } "#$gh/@$name" } else { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 8d38c678..4f4f53da 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -61,13 +61,13 @@ fun MeshTopologySection( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) - Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) } val nodes = snapshot.nodes val edges = snapshot.edges val empty = nodes.isEmpty() if (empty) { - Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { ForceDirectedMeshGraph( nodes = nodes, @@ -88,7 +88,7 @@ fun MeshTopologySection( verticalArrangement = Arrangement.spacedBy(4.dp) ) { nodes.forEach { node -> - val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}" + val label = "${node.peerID.take(8)} • ${node.nickname ?: "Unknown"}" Text( text = label, fontFamily = FontFamily.Monospace, @@ -388,7 +388,7 @@ fun DebugSettingsSheet( } Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(24.dp)) - Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware verbose logging", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) } } @@ -719,7 +719,7 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99) @@ -745,7 +745,7 @@ fun DebugSettingsSheet( val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() } Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) if (connectedDevices.isEmpty()) { - Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("None", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { connectedDevices.forEach { dev -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0818a9f1..93579e5b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,8 +4,8 @@ Bluetooth permission is required for peer-to-peer messaging without internet. Location permission is required to discover nearby devices via Bluetooth. Notification permission is required to alert you of new messages. - nickname - type a message… + Nickname + Type a message… Password Join Channel Leave @@ -54,7 +54,7 @@ Messages in %1$s %1$s joined the conversation bitchat - %1$d mentions - bitchat - location chats + bitchat - Location chats %1$d messages from %2$d locations Mentioned in Mesh Chat %1$d mentions in Mesh Chat @@ -72,8 +72,8 @@ Add bookmark - back - leave + Back + Leave Nostr reachable Unread private messages Location notes @@ -152,34 +152,34 @@ Geohash channels connect you with people nearby, extending the mesh over public relays. End-to-End Encryption Private messages are encrypted. Channel messages are public. - system - light - dark - proof of work - pow off - pow on - add proof of work to geohash messages for spam deterrence. - difficulty: %1$d bits (~%2$s) - difficulty %1$d requires ~%2$s hash attempts - no proof of work required - very low - minimal spam protection - low - basic spam protection - medium - good spam protection - high - strong spam protection - very high - may cause delays - extreme - significant computation required - network - tor off - tor on - route internet over tor for enhanced privacy. - tor Status: %1$s, bootstrap %2$d%% + System + Light + Dark + Proof of Work + PoW Off + PoW On + Add proof of work to geohash messages for spam deterrence. + Difficulty: %1$d bits (~%2$s) + Difficulty %1$d requires ~%2$s hash attempts + No proof of work required + Very low – minimal spam protection + Low – basic spam protection + Medium – good spam protection + High – strong spam protection + Very high – may cause delays + Extreme – significant computation required + Network + Tor Off + Tor On + Route internet over Tor for enhanced privacy. + Tor Status: %1$s, bootstrap %2$d%% Last: %1$s Panic Mode Debug Settings Open Source • Privacy First • Decentralized Close Privacy Protected - cancel + Cancel Warning @@ -196,8 +196,8 @@ Remove bookmark Teleport Quit bitchat - run in background - keep mesh active when app is closed (foreground service) + Run in Background + Keep mesh active when app is closed (foreground service) Leave channel Reachable via Nostr Offline favorite @@ -214,8 +214,8 @@ Mining PoW PoW Enabled Proof of Work - mining... - pow: %1$dbit + Mining… + PoW: %1$dbit Offline Mesh Chat Online Geohash Channels End-to-End Encryption @@ -227,27 +227,27 @@ Failed to save image Pick file [file unavailable] - unknown + Unknown - choose an action for this message or user - choose an action for this user - copy message - copy this message to clipboard - slap %1$s - send a playful slap message - hug %1$s - send a friendly hug message - block %1$s - block all messages from this user - message %1$s - send a private message + Choose an action for this message or user + Choose an action for this user + Copy message + Copy this message to clipboard + Slap %1$s + Send a playful slap message + Hug %1$s + Send a friendly hug message + Block %1$s + Block all messages from this user + Message %1$s + Send a private message - #location channels + #Location Channels Location Channels - 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. + 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. Nearby @@ -260,113 +260,113 @@ On location Teleported in - grant location permission - location permission denied. enable in settings to use location channels. + Grant location permission + Location permission denied. Enable in settings to use location channels. - \u2713 location permission granted - checking permissions... - finding nearby channels… - bookmarked + \u2713 Location permission granted + Checking permissions… + Finding nearby channels… + Bookmarked geohash - invalid geohash - teleport + Invalid geohash + Teleport Remove location access - enable location services + Enable location services mesh - block - neighborhood - city - province - region + Block + Neighborhood + City + Province + Region #%1$s ± 1 • %2$d note #%1$s ± 1 • %2$d notes - add short permanent notes to this location for other visitors to find. - geo relays unavailable; notes paused - no geo relays nearby - notes rely on geo relays. check connection and try again. - loading notes… - no notes yet - be the first to add one for this spot. - dismiss - add a note for this place + Add short permanent notes to this location for other visitors to find. + Geo relays unavailable; notes paused + No geo relays nearby + Notes rely on geo relays. Check connection and try again. + Loading notes… + No notes yet + Be the first to add one for this spot. + Dismiss + Add a note for this place - debug tools - developer utilities for diagnostics and control - verbose logging - logs peer joins/leaves, connection direction, packet routing and relays - bluetooth roles - gatt server - connections: %1$d / %2$d - max server - gatt client - max client - connections: %1$d / %2$d - max overall - packet relay - since start: %1$d - turn roles on/off and close all connections when disabled - sync settings - max packets per sync: %1$d - max GCS filter size: %1$d bytes (128–1024) - target FPR: %1$.2f%% - connected devices - our device id: %1$s - none - disconnect - recent scan results + Debug Tools + Developer utilities for diagnostics and control + Verbose logging + Logs peer joins/leaves, connection direction, packet routing and relays + Bluetooth roles + GATT server + Connections: %1$d / %2$d + Max server + GATT client + Max client + Connections: %1$d / %2$d + Max overall + Packet relay + Since start: %1$d + Turn roles on/off and close all connections when disabled + Sync settings + Max packets per sync: %1$d + Max GCS filter size: %1$d bytes (128–1024) + Target FPR: %1$.2f%% + Connected devices + Our device ID: %1$s + None + Disconnect + Recent scan results - verify - scan to verify me - scan someone elses qr - scan someone elses qr - show my qr - remove verification - qr unavailable - camera permission is needed to scan qr codes - enable camera - paste verification url - validate - verification requested - security verification - their fingerprint - your fingerprint - handshake pending - open a private chat to view fingerprints - encrypted & verified - encrypted - handshaking - handshake failed - not encrypted - verified - you have verified this persons identity. - not verified - compare these fingerprints with %1$s using a secure channel. - mark as verified - start handshake - copy + Verify + Scan to verify me + Scan someone else\'s QR + Scan someone else\'s QR + Show my QR + Remove verification + QR unavailable + Camera permission is needed to scan QR codes + Enable camera + Paste verification URL + Validate + Verification requested + Security verification + Their fingerprint + Your fingerprint + Handshake pending + Open a private chat to view fingerprints + Encrypted & verified + Encrypted + Handshaking + Handshake failed + Not encrypted + Verified + You have verified this person\'s identity. + Not verified + Compare these fingerprints with %1$s using a secure channel. + Mark as verified + Start handshake + Copy Mutual verification You and %1$s verified each other - mutual verification with %1$s + Mutual verification with %1$s Verified You verified %1$s - verified %1$s + Verified %1$s - connect - debug console - clear - last 10s: %1$d • 1m: %2$d • 15m: %3$d - derived P: %1$s • est. max elements: %2$s + Connect + Debug console + Clear + Last 10s: %1$d • 1m: %2$d • 15m: %3$d + Derived P: %1$s • est. max elements: %2$s • direct RSSI: %1$s ? - as server (we host) - as client (we connect) + As server (we host) + As client (we connect) Location Services Required @@ -375,11 +375,11 @@ bitchat needs location services for: • Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection Background Location Recommended - optional, improves mesh reliability + Optional, improves mesh reliability Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot. When settings opens, choose "Allow all the time". bitchat uses background location for: - - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background + - Scan for nearby devices while the app is closed\n- Reconnect after reboot\n- Keep the mesh running in the background We NEVER collect or store your location. Your privacy is safe. Allow Background Location Continue without background location @@ -395,13 +395,13 @@ Bluetooth Not Supported This device doesn\'t support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet. Checking Bluetooth status... - battery optimization detected + Battery optimization detected Battery Optimization Enabled - bitchat needs to run in the background to maintain mesh connections. battery optimization can interrupt these connections. + bitchat needs to run in the background to maintain mesh connections. Battery optimization can interrupt these connections. Benefits of Disabling - • reliable message delivery\n• maintains mesh connectivity\n• prevents connection drops + • Reliable message delivery\n• Maintains mesh connectivity\n• Prevents connection drops Disable Battery Optimization - battery optimization disabled + Battery optimization disabled Continue Initializing mesh network . @@ -412,20 +412,20 @@ Try Again Open Settings Your Privacy is Protected - • no tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet - permissions + • No tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet + Permissions Grant Permissions bitchat does NOT track your location @ Open About · ⧉ - nobody around... + Nobody around… (you) - pan and zoom to select a geohash - select + Pan and zoom to select a geohash + Select Type a message… @%1$s - mention + Mention %1$d / %2$d (~%1$s) @%1$s From 53a891a71fee2e35dfd1b257e7dcbc83af95c5a6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:58:06 +0200 Subject: [PATCH 05/37] strings --- .../main/java/com/bitchat/android/ui/LocationChannelsSheet.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 3f3e59cf..cc060e47 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -751,7 +751,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String { val meshCount = meshCount(viewModel) val ctx = androidx.compose.ui.platform.LocalContext.current val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount) - val meshLabel = stringResource(com.bitchat.android.R.string.mesh_label) + val meshLabel = stringResource(com.bitchat.android.R.string.mesh_title) return "$meshLabel [$peopleText]" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93579e5b..62d3ad1d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,6 +273,7 @@ Remove location access Enable location services mesh + Mesh Block Neighborhood City From 893e7d3875ac2bae970d43df1e511bd24ba00768 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:11:11 +0200 Subject: [PATCH 06/37] input bar fixes --- .../com/bitchat/android/ui/AboutSections.kt | 54 --- .../java/com/bitchat/android/ui/AboutSheet.kt | 56 +-- .../java/com/bitchat/android/ui/ChatHeader.kt | 34 +- .../com/bitchat/android/ui/InputComponents.kt | 401 ++++++++++++------ .../bitchat/android/ui/LocationNotesButton.kt | 25 +- .../android/ui/VoiceInputComponents.kt | 48 +-- .../android/ui/media/ImagePickerButton.kt | 49 +-- .../android/ui/theme/BitchatPalette.kt | 15 + app/src/main/res/values/strings.xml | 4 - 9 files changed, 382 insertions(+), 304 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index 3df4fe5d..ee432a26 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -6,7 +6,6 @@ import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Person 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 @@ -452,59 +451,6 @@ private fun AboutFeatureRow( } } -/** - * 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. */ diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 9b3d6836..6be3cff1 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -2,9 +2,6 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Speed import androidx.compose.animation.animateColorAsState @@ -24,7 +21,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -41,52 +37,6 @@ 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 - */ -@Composable -private fun FeatureRow( - 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, - color = palette.textSecondary, - lineHeight = 17.sp - ) - } - } -} - /** * Theme selection chip with Apple-like styling */ @@ -566,10 +516,6 @@ fun AboutSheet( } } - // Security audit warning - item(key = "warning") { - AboutWarningCard(modifier = Modifier.padding(top = 24.dp)) - } } // end Settings tab // Footer @@ -585,7 +531,7 @@ fun AboutSheet( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - if (onShowDebug != null) { + if (selectedTab == AboutTab.Settings && onShowDebug != null) { TextButton(onClick = onShowDebug) { Text( text = stringResource(R.string.about_debug_settings), diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 6576a712..052201b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -510,20 +510,26 @@ private fun MainHeader( } } - // Location Notes button (extracted to separate component) - LocationNotesButton( - viewModel = viewModel, - onClick = onLocationNotesClick - ) + // Location notes + channel badge: one tight unit so the document glyph and the + // bluetooth/globe glyph sit at the same visual pitch as other header pairings. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + LocationNotesButton( + viewModel = viewModel, + onClick = onLocationNotesClick + ) - // Bookmarking lives in the Location Channels sheet, one tap away via the channel - // button below. Duplicating it here bought a shortcut for a rare action at the cost - // of a slot in the app's most crowded row. + // Bookmarking lives in the Location Channels sheet, one tap away via the channel + // button. Duplicating it here bought a shortcut for a rare action at the cost + // of a slot in the app's most crowded row. - LocationChannelsButton( - viewModel = viewModel, - onClick = onLocationChannelsClick - ) + LocationChannelsButton( + viewModel = viewModel, + onClick = onLocationChannelsClick + ) + } PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, @@ -574,7 +580,9 @@ private fun LocationChannelsButton( .clip(HeaderClusterShape) .clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() } .height(HeaderTapTarget) - .padding(horizontal = 6.dp) + // No start padding: the notes icon is paired directly to the left; keep end + // padding so the gap to PeerCounter matches other cluster separations. + .padding(start = 0.dp, end = 6.dp) ) { Icon( imageVector = badgeIcon, diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 938729ce..e97a1c95 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -4,10 +4,26 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,7 +34,11 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource @@ -174,23 +194,96 @@ class CombinedVisualTransformation(private val transformations: List Unit +) { + val palette = LocalBitchatPalette.current + val accent = if (activeColor == Color.Unspecified) palette.accentGreen else activeColor + + val container by animateColorAsState( + // A tint rather than a fill. A solid accent disc next to the text you are typing was the + // loudest thing on the screen; at 20% it still reads as "armed" without competing. + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.surfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonContainer" + ) + val tint by animateColorAsState( + targetValue = if (isActive) accent else palette.textSecondary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonTint" + ) + // A small dip on press. Spring rather than tween so the release overshoots very slightly and + // the button feels physical instead of merely animated. + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.88f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "composerButtonScale" + ) + + Box( + modifier = modifier.size(ComposerButtonSize), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(ComposerButtonDisc) + .scale(scale) + .background(container, CircleShape) + .semantics { contentDescription?.let { this.contentDescription = it } }, + contentAlignment = Alignment.Center + ) { + content(tint) + } + } +} @Composable fun MessageInput( @@ -206,60 +299,72 @@ fun MessageInput( showMediaButtons: Boolean, 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 + val hasText = value.text.isNotBlank() val focusRequester = remember { FocusRequester() } var isRecording by remember { mutableStateOf(false) } var elapsedMs by remember { mutableStateOf(0L) } var amplitude by remember { mutableStateOf(0) } - // Recording turns the pill's outline red; a cross-fade keeps that from flashing. + // Recording is the one state worth shouting about, so it overrides focus. val borderColor by animateColorAsState( targetValue = when { - isRecording -> palette.accentRed.copy(alpha = 0.6f) - isFocused.value -> palette.outline - else -> palette.outlineVariant + isRecording -> palette.accentRed.copy(alpha = 0.7f) + isFocused.value -> palette.inputOutlineFocused + else -> palette.inputOutline }, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerBorder" ) + // A barely-there lift on focus. Enough to register, not enough to look like a different + // component. + val containerColor by animateColorAsState( + targetValue = if (isFocused.value) palette.surfaceVariant else palette.surface, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerContainer" + ) 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) + modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom ) { - // MARK: - The pill. Holds the field and the send button as one visual object. + // MARK: - The pill. Field and action buttons are one visual object. Row( modifier = Modifier .weight(1f) .heightIn(min = ComposerMinHeight) - .background(palette.surfaceVariant, ComposerShape) + // Grow smoothly as the field wraps to more lines rather than jumping a line at + // a time. + .animateContentSize( + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ) + .background(containerColor, 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) + .padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp) ) { // Always keep the text field mounted to retain focus and avoid IME collapse BasicTextField( value = value, onValueChange = onValueChange, + // Near-white, not terminal green: this is the one place in the app where the + // user is composing rather than reading, and green-on-black is tiring to + // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.primary, + color = palette.textPrimary, fontFamily = FontFamily.Monospace ), - cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), + cursorBrush = SolidColor( + if (isRecording) Color.Transparent else palette.textPrimary + ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { - if (hasText) onSend() // Only send if there's text + if (hasText) onSend() }), // Cap the growth so a pasted wall of text cannot swallow the message list. maxLines = 6, @@ -285,34 +390,52 @@ fun MessageInput( } ) - // Show placeholder when there's no text and not recording - if (value.text.isEmpty() && !isRecording) { + // Placeholder fades rather than blinking, which matters because it reappears + // every time a message is sent. + val placeholderAlpha by animateFloatAsState( + targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "placeholderAlpha" + ) + if (placeholderAlpha > 0f) { Text( text = stringResource(R.string.type_a_message_placeholder), style = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace ), color = palette.textTertiary, - modifier = Modifier.fillMaxWidth() + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .alpha(placeholderAlpha) ) } - // Overlay the real-time scrolling waveform while recording + // Recording visualiser, layered over the (empty) field. + val waveformAlpha by animateFloatAsState( + targetValue = if (isRecording) 1f else 0f, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "waveformAlpha" + ) if (isRecording) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(waveformAlpha), + verticalAlignment = Alignment.CenterVertically + ) { RealtimeScrollingWaveform( - modifier = Modifier.weight(1f).height(24.dp), + modifier = Modifier.weight(1f).height(22.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), + text = String.format( + "%02d:%02d / %02d:%02d", + secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 + ), fontFamily = FontFamily.Monospace, color = palette.accentRed, fontSize = (BASE_FONT_SIZE - 4).sp @@ -321,60 +444,116 @@ fun MessageInput( } } - SendButton( - hasText = hasText, - isAccented = selectedPrivatePeer != null || currentChannel != null, - onSend = onSend, - modifier = Modifier.padding(end = 4.dp, bottom = 4.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 + // MARK: - Action cluster, inside the pill. + // + // Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and + // scales between the two, and SizeTransform animates the width change, so typing the + // first character morphs camera+mic into send instead of snapping. val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer) val latestChannel = rememberUpdatedState(currentChannel) val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote) - // Image button (image picker) - hide during recording - if (!isRecording) { - ImagePickerButton( - onImageReady = { outPath -> - onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath) + AnimatedContent( + targetState = hasText, + transitionSpec = { + ( + fadeIn(tween(BitchatMotion.STANDARD_MS)) + + scaleIn( + initialScale = 0.7f, + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + ).togetherWith( + fadeOut(tween(BitchatMotion.QUICK_MS)) + + scaleOut( + targetScale = 0.7f, + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ) + ) + ) using SizeTransform(clip = false) { _, _ -> + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) } - ) - } + }, + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp), + label = "composerActions" + ) { showSend -> + if (showSend) { + SendButton( + isAccented = latestSelectedPeer.value != null || latestChannel.value != null, + onSend = onSend + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showMediaButtons) { + // The camera steps aside while recording so the microphone is the + // only thing that can be released. + AnimatedVisibility( + visible = !isRecording, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandHorizontally( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkHorizontally( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + ImagePickerButton( + onImageReady = { outPath -> + onSendImageNote( + latestSelectedPeer.value, + latestChannel.value, + outPath + ) + } + ) + } - VoiceRecordButton( - backgroundColor = colorScheme.primary, - onStart = { - isRecording = true - elapsedMs = 0L - // Keep existing focus to avoid IME collapse, but do not force-show keyboard - if (isFocused.value) { - try { focusRequester.requestFocus() } catch (_: Exception) {} - } - }, - onAmplitude = { amp, ms -> - amplitude = amp - elapsedMs = ms - }, - onFinish = { path -> - isRecording = false - // Extract and cache waveform from the actual audio file to match receiver rendering - AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> - if (arr != null) { - try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {} + VoiceRecordButton( + isRecording = isRecording, + onStart = { + isRecording = true + elapsedMs = 0L + // Keep existing focus to avoid IME collapse, but do not + // force-show the keyboard. + if (isFocused.value) { + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + }, + onAmplitude = { amp, ms -> + amplitude = amp + elapsedMs = ms + }, + onFinish = { path -> + isRecording = false + // Extract and cache the waveform from the actual audio file + // so it matches the receiver's rendering. + AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr -> + if (arr != null) { + try { + com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) + } catch (_: Exception) {} + } + } + latestOnSendVoiceNote.value( + latestSelectedPeer.value, + latestChannel.value, + path + ) + } + ) + } else { + // No media in this context, so keep an inert send button rather than + // leaving a hole where the action cluster should be. + SendButton(isAccented = false, onSend = {}, enabled = false) } } - // BLE path (private or public) — use latest values to avoid stale captures - latestOnSendVoiceNote.value( - latestSelectedPeer.value, - latestChannel.value, - path - ) } - ) + } } } @@ -382,51 +561,31 @@ fun MessageInput( } /** - * 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. + * Send affordance. Only rendered when there is something to send, so its mere presence is the + * signal; it does not need to shout in the terminal's full-brightness green as well. */ @Composable private fun SendButton( - hasText: Boolean, isAccented: Boolean, onSend: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { val palette = LocalBitchatPalette.current - val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() - 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 - ) { + ComposerActionSurface( + isActive = enabled, + isPressed = isPressed, + // Private chats and channels keep their orange identity, disc and glyph together. + activeColor = if (isAccented) palette.accentOrange else palette.accentGreen, + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onSend() } + ) { tint -> Icon( imageVector = Icons.Filled.ArrowUpward, contentDescription = stringResource(id = R.string.send_message), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 1ec6417e..185a053e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -2,16 +2,19 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -34,7 +37,7 @@ fun LocationNotesButton( ) { val colorScheme = MaterialTheme.colorScheme val context = LocalContext.current - + // Get channel and permission state val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } @@ -44,7 +47,7 @@ fun LocationNotesButton( // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationEnabled = locationPermissionGranted && locationServicesEnabled - + // Get notes count from LocationNotesManager val notesManager = remember { LocationNotesManager.getInstance() } val notes by notesManager.notes.collectAsStateWithLifecycle() @@ -53,13 +56,19 @@ fun LocationNotesButton( // Only show in mesh mode when location is authorized (iOS pattern) if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 - IconButton( - onClick = onClick, - modifier = modifier.size(44.dp) + val contentDescription = stringResource(R.string.cd_location_notes) + // Match other header icon buttons: 44.dp target, no Material IconButton min-size padding + // that pushed the notes glyph farther from the mesh badge than sibling gaps. + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .clickable(onClickLabel = contentDescription) { onClick() }, + contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Outlined.Description, - contentDescription = stringResource(R.string.cd_location_notes), + contentDescription = contentDescription, modifier = Modifier.size(HeaderIconSize), tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary ) diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index cdc40583..bda3aee4 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -3,22 +3,16 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic import android.Manifest -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.unit.dp import com.bitchat.android.features.voice.VoiceRecorder import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus @@ -32,7 +26,11 @@ import kotlinx.coroutines.launch @Composable fun VoiceRecordButton( modifier: Modifier = Modifier, - backgroundColor: Color, + /** + * Recording state as the composer sees it. Drives the active tint so the button and the + * pill's border change together instead of one lagging the other. + */ + isRecording: Boolean = false, onStart: () -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, onFinish: (filePath: String) -> Unit @@ -41,7 +39,7 @@ fun VoiceRecordButton( val haptic = LocalHapticFeedback.current val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) - var isRecording by remember { mutableStateOf(false) } + var isCapturing by remember { mutableStateOf(false) } var recorder by remember { mutableStateOf(null) } var recordedFilePath by remember { mutableStateOf(null) } var recordingStart by remember { mutableStateOf(0L) } @@ -54,14 +52,15 @@ fun VoiceRecordButton( val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) - Box( + // Same disc, same sizing and the same press feedback as the camera and send buttons. + ComposerActionSurface( + isActive = isRecording || isCapturing, + isPressed = isCapturing, modifier = modifier - .size(36.dp) - .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isRecording) { + if (!isCapturing) { if (micPermission.status !is PermissionStatus.Granted) { micPermission.launchPermissionRequest() return@detectTapGestures @@ -69,24 +68,24 @@ fun VoiceRecordButton( val rec = VoiceRecorder(context) val f = rec.start() recorder = rec - isRecording = f != null + isCapturing = f != null recordedFilePath = f?.absolutePath recordingStart = System.currentTimeMillis() - if (isRecording) { + if (isCapturing) { latestOnStart.value() // Haptic "knock" when recording starts try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} // Start amplitude polling loop ampJob?.cancel() ampJob = scope.launch { - while (isActive && isRecording) { + while (isActive && isCapturing) { val amp = recorder?.pollAmplitude() ?: 0 val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) latestOnAmplitude.value(amp, elapsedMs) // Auto-stop after 10 seconds - if (elapsedMs >= 10_000 && isRecording) { + if (elapsedMs >= 10_000 && isCapturing) { val file = recorder?.stop() - isRecording = false + isCapturing = false recorder = null val path = file?.absolutePath if (!path.isNullOrBlank()) { @@ -104,13 +103,13 @@ fun VoiceRecordButton( try { awaitRelease() } finally { - if (isRecording) { + if (isCapturing) { // Extend recording for 500ms after release to avoid clipping delay(500) } - if (isRecording) { + if (isCapturing) { val file = recorder?.stop() - isRecording = false + isCapturing = false recorder = null val path = (file?.absolutePath ?: recordedFilePath) recordedFilePath = null @@ -125,14 +124,13 @@ fun VoiceRecordButton( } } ) - }, - contentAlignment = Alignment.Center - ) { + } + ) { tint -> Icon( imageVector = Icons.Filled.Mic, contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice), - tint = Color.Black, - modifier = Modifier.size(22.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index df6f8ea7..97ee01a4 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt @@ -11,22 +11,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape 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 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 com.bitchat.android.ui.ComposerActionSurface +import com.bitchat.android.ui.ComposerIconSize import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -89,28 +87,31 @@ fun ImagePickerButton( } } - Box( - modifier = modifier - // Matches the header's tap targets and the composer's send button. - .size(44.dp) - .clip(CircleShape) - .combinedClickable( - onClick = { imagePicker.launch("image/*") }, - onLongClick = { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - startCameraCapture() - } else { - permissionLauncher.launch(Manifest.permission.CAMERA) - } + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + // Shares the composer's button treatment so camera, microphone and send read as one set. + ComposerActionSurface( + isActive = false, + isPressed = isPressed, + modifier = modifier.combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = { imagePicker.launch("image/*") }, + onLongClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + startCameraCapture() + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) } - ), - contentAlignment = Alignment.Center - ) { + } + ) + ) { tint -> Icon( imageVector = Icons.Filled.PhotoCamera, contentDescription = stringResource(com.bitchat.android.R.string.pick_image), - tint = LocalBitchatPalette.current.textSecondary, - modifier = Modifier.size(22.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index bc5e7b03..e7f1a9af 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -33,6 +33,17 @@ data class BitchatPalette( /** Hairline dividers. */ val outlineVariant: Color, + // MARK: - Form controls + /** + * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted + * [outline]: the composer is the one surface the user stares at while typing, and a green + * cast there made the whole bottom of the screen read as "terminal chrome" instead of a + * place to write. + */ + val inputOutline: Color, + /** Border for a focused text input. A step brighter, still neutral. */ + val inputOutlineFocused: Color, + // MARK: - Text /** Message bodies and row titles. Neutral, not green. */ val textPrimary: Color, @@ -64,6 +75,8 @@ val DarkBitchatPalette = BitchatPalette( surfaceVariant = Color(0xFF182118), outline = Color(0xFF2A3A2A), outlineVariant = Color(0xFF1C271C), + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), textPrimary = Color(0xFFE8EDE8), textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), @@ -81,6 +94,8 @@ val LightBitchatPalette = BitchatPalette( surfaceVariant = Color(0xFFE7EDE7), outline = Color(0xFFCBD6CB), outlineVariant = Color(0xFFDEE6DE), + inputOutline = Color(0xFFCFD3D1), + inputOutlineFocused = Color(0xFF8E9490), textPrimary = Color(0xFF131A13), textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62d3ad1d..3b7a7022 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,10 +135,6 @@ A new peer ID is generated regularly. Triple-tap the logo to instantly clear all data. - - Warning - Private message security has not yet been fully audited. Do not use for critical situations until this warning disappears. - Tor Network Difficulty From 02a737fad6c4a56c7df1678f4b9831e73e91a22f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:26:15 +0200 Subject: [PATCH 07/37] fixes --- .../com/bitchat/android/ui/AboutSections.kt | 97 ++++------ .../java/com/bitchat/android/ui/ChatHeader.kt | 11 -- .../java/com/bitchat/android/ui/ChatScreen.kt | 161 +++++++++------- .../com/bitchat/android/ui/InputComponents.kt | 24 ++- .../bitchat/android/ui/MeshPeerListSheet.kt | 8 +- .../bitchat/android/ui/MessageComponents.kt | 18 +- .../android/ui/VoiceInputComponents.kt | 175 +++++++++++++----- .../android/ui/theme/BitchatPalette.kt | 16 ++ 8 files changed, 322 insertions(+), 188 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index ee432a26..af17a509 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -4,7 +4,6 @@ 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.Person -import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material.icons.outlined.BookmarkBorder @@ -339,71 +338,40 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { } /** - * 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. + * Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers. */ @Composable internal fun AboutFeatureCard(modifier: Modifier = Modifier) { - val palette = LocalBitchatPalette.current + val features = listOf( + Triple( + Icons.Filled.WifiOff, + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc + ), + Triple( + Icons.Outlined.Lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + Icons.Outlined.Public, + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc + ), + Triple( + Icons.Outlined.VisibilityOff, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + ) - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - val features = listOf( - Triple( - Icons.Filled.WifiOff, - R.string.about_offline_mesh_title, - R.string.about_offline_mesh_desc - ), - Triple( - Icons.Outlined.Lock, - R.string.about_e2e_title, - R.string.about_e2e_desc - ), - Triple( - Icons.Outlined.Public, - R.string.about_online_geohash_title, - R.string.about_online_geohash_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_emergency_title, - R.string.about_panic_desc - ), + Column(modifier = modifier.fillMaxWidth()) { + features.forEach { (icon, titleRes, descRes) -> + AboutFeatureRow( + icon = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) ) - - 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) - ) - } } } } @@ -420,7 +388,8 @@ private fun AboutFeatureRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 13.dp), + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top ) { Icon( @@ -429,15 +398,15 @@ private fun AboutFeatureRow( tint = colorScheme.primary, modifier = Modifier .padding(top = 1.dp) - .size(24.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, + lineHeight = 20.sp, color = palette.textPrimary ) Text( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 052201b0..c6cb546f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -560,7 +560,6 @@ private fun LocationChannelsButton( // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() - val teleported by viewModel.isTeleported.collectAsStateWithLifecycle() val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location val badgeText = when (val channel = selectedChannel) { @@ -599,15 +598,5 @@ private fun LocationChannelsButton( color = badgeColor, maxLines = 1 ) - - // Teleportation indicator (like iOS) - if (teleported) { - Icon( - imageVector = Icons.Default.PinDrop, - contentDescription = stringResource(R.string.cd_teleported), - modifier = Modifier.size(HeaderIconSize), - tint = badgeColor - ) - } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 6f4ff8a3..2d721bba 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -14,11 +14,14 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.IconButton +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -120,7 +123,14 @@ fun ChatScreen(viewModel: ChatViewModel) { .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { val headerHeight = ChatHeaderHeight - + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + // Both bars are translucent and the conversation scrolls underneath them, so their + // heights are reserved as list padding instead of as layout space. The composer's height + // varies (suggestion rows, wrapped lines), so it is measured rather than assumed. + var composerHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + // Main content area that responds to keyboard/window insets Column( modifier = Modifier @@ -128,19 +138,17 @@ fun ChatScreen(viewModel: ChatViewModel) { .windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets .windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded ) { - // Header spacer - creates exact space for the floating header (status bar + compact header) - Spacer( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(headerHeight) - ) - + Box(modifier = Modifier.weight(1f)) { // Messages area - takes up available space, will compress when keyboard appears MessagesList( messages = displayMessages, currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = statusBarHeight + headerHeight, + bottom = composerHeight + ), forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { fullSenderName -> @@ -188,7 +196,7 @@ fun ChatScreen(viewModel: ChatViewModel) { showFullScreenImageViewer = true } ) - // Input area - stays at bottom + // Input area - overlays the bottom of the conversation // Bridge file share from lower-level input to ViewModel androidx.compose.runtime.LaunchedEffect(Unit) { com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path -> @@ -197,6 +205,11 @@ fun ChatScreen(viewModel: ChatViewModel) { } ChatInputSection( + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { size -> + composerHeight = with(density) { size.height.toDp() } + }, messageText = messageText, onMessageTextChange = { newText: TextFieldValue -> messageText = newText @@ -244,6 +257,7 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, showMediaButtons = showMediaButtons ) + } } // Floating header - positioned absolutely at top, ignores keyboard @@ -261,17 +275,6 @@ fun ChatScreen(viewModel: ChatViewModel) { onLocationNotesClick = { showLocationNotesSheet = true } ) - // Divider under header - positioned after status bar + header height - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .offset(y = headerHeight) - .zIndex(1f), - thickness = 1.dp, - color = palette.outlineVariant - ) - // Scroll-to-bottom floating button AnimatedVisibility( visible = isScrolledUp, @@ -287,7 +290,7 @@ fun ChatScreen(viewModel: ChatViewModel) { ) + fadeOut(tween(BitchatMotion.QUICK_MS)), modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 16.dp, bottom = 64.dp) + .padding(end = 16.dp, bottom = composerHeight + 8.dp) .zIndex(1.5f) .windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.ime) @@ -407,50 +410,73 @@ fun ChatInputSection( currentChannel: String?, nickname: String, colorScheme: ColorScheme, - showMediaButtons: Boolean + showMediaButtons: Boolean, + modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.background + + Column( + // Flat, slightly translucent screen background — the same treatment as the top bar, so the + // two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked + // like a smudge above a crisp hairline. The rule is inside the background so the whole bar + // is one surface with a top border, rather than a line floating over the conversation. + modifier = modifier + .fillMaxWidth() + .background(colorScheme.background.copy(alpha = BarBackgroundAlpha)) ) { - // 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 { - // Command suggestions box - if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { - CommandSuggestionsBox( - suggestions = commandSuggestions, - onSuggestionClick = onCommandSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) - } - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { - MentionSuggestionsBox( - suggestions = mentionSuggestions, - onSuggestionClick = onMentionSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) - } - MessageInput( - value = messageText, - onValueChange = onMessageTextChange, - onSend = onSend, - onSendVoiceNote = onSendVoiceNote, - onSendImageNote = onSendImageNote, - onSendFileNote = onSendFileNote, - selectedPrivatePeer = selectedPrivatePeer, - currentChannel = currentChannel, - nickname = nickname, - showMediaButtons = showMediaButtons, + // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + + // Command suggestions box + if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { + CommandSuggestionsBox( + suggestions = commandSuggestions, + onSuggestionClick = onCommandSuggestionClick, modifier = Modifier.fillMaxWidth() ) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } + // Mention suggestions box + if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { + MentionSuggestionsBox( + suggestions = mentionSuggestions, + onSuggestionClick = onMentionSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + } + MessageInput( + value = messageText, + onValueChange = onMessageTextChange, + onSend = onSend, + onSendVoiceNote = onSendVoiceNote, + onSendImageNote = onSendImageNote, + onSendFileNote = onSendFileNote, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + showMediaButtons = showMediaButtons, + modifier = Modifier.fillMaxWidth() + ) } } + +/** + * Opacity shared by both bars. + * + * Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads + * as sitting over the content rather than boxing it in — without ever costing legibility. + */ +private const val BarBackgroundAlpha = 0.88f + +/** + * Fraction of the header that stays fully opaque, measured from the top. + * + * The header is the one place a gradient earns its keep: the status bar is transparent, so the + * header has to be the true background colour where the two meet or the system bar stops looking + * like part of the app. Everything below that stop matches the composer's flat translucency. + */ +private const val HeaderOpaqueStop = 0.72f @Composable private fun ChatFloatingHeader( headerHeight: Dp, @@ -467,13 +493,24 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - - Surface( + val palette = LocalBitchatPalette.current + + Box( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area - color = colorScheme.background // Solid background color extending into status bar + // Fully opaque where it meets the system status bar, fading to translucent at its + // lower edge. The status bar itself is transparent, so anything less than opaque at + // the top would let the wallpaper or a light system-bar scrim bleed through and the + // header would stop reading as part of the app. + .background( + Brush.verticalGradient( + 0f to colorScheme.background, + HeaderOpaqueStop to colorScheme.background, + 1f to colorScheme.background.copy(alpha = BarBackgroundAlpha) + ) + ) + .windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area ) { // 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 diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index e97a1c95..d673bb79 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -220,6 +220,15 @@ private val ComposerButtonDisc = 36.dp /** Icon size shared by the composer's glyphs. */ internal val ComposerIconSize = 20.dp +/** + * Opacity of the composer pill. + * + * Not fully opaque: the message list scrolls underneath the composer, and letting a hint of it + * through is what makes the bar read as sitting *over* the conversation rather than boxing it in. + * Kept high enough that text in the field never loses contrast. + */ +private const val ComposerFillAlpha = 0.88f + /** * The shared visual treatment for every button in the composer: camera, microphone, send. * @@ -248,7 +257,7 @@ internal fun ComposerActionSurface( val container by animateColorAsState( // A tint rather than a fill. A solid accent disc next to the text you are typing was the // loudest thing on the screen; at 20% it still reads as "armed" without competing. - targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.surfaceVariant, + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerButtonContainer" ) @@ -318,9 +327,10 @@ fun MessageInput( label = "composerBorder" ) // A barely-there lift on focus. Enough to register, not enough to look like a different - // component. + // component. Slightly translucent so the messages scrolling underneath stay faintly visible. val containerColor by animateColorAsState( - targetValue = if (isFocused.value) palette.surfaceVariant else palette.surface, + targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) + .copy(alpha = ComposerFillAlpha), animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerContainer" ) @@ -544,6 +554,14 @@ fun MessageInput( latestChannel.value, path ) + }, + // Any capture that ends without a file must clear the recording + // state here too, otherwise the pill stays red with a live + // waveform over an empty field. + onCancel = { + isRecording = false + amplitude = 0 + elapsedMs = 0L } ) } else { diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 180b35c8..880c0cc6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -860,6 +860,7 @@ fun PrivateChatSheet( val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) + val palette = LocalBitchatPalette.current if (isPresented) { BitchatBottomSheet( @@ -872,7 +873,7 @@ fun PrivateChatSheet( ) { Spacer(modifier = Modifier.height(64.dp)) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } @@ -891,9 +892,8 @@ fun PrivateChatSheet( onImageClick = { _, _, _ -> /* handle image click */ } ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - - // Input section + // Input section. No divider here: ChatInputSection draws its own fade and + // hairline. var messageText by remember { mutableStateOf( androidx.compose.ui.text.input.TextFieldValue( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 83db678a..ad763aa7 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -37,6 +39,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -72,6 +75,13 @@ fun MessagesList( currentUserNickname: String, meshService: MeshService, modifier: Modifier = Modifier, + /** + * Extra inset on top of the list's own gutters. + * + * The chat screen's bars are translucent and the list scrolls underneath them, so the caller + * has to reserve room for their heights here rather than by shrinking the viewport. + */ + contentPadding: PaddingValues = PaddingValues(0.dp), forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null, @@ -119,11 +129,17 @@ fun MessagesList( } } + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, // 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), + contentPadding = PaddingValues( + start = 16.dp + contentPadding.calculateStartPadding(layoutDirection), + end = 16.dp + contentPadding.calculateEndPadding(layoutDirection), + top = 8.dp + contentPadding.calculateTopPadding(), + bottom = 12.dp + contentPadding.calculateBottomPadding() + ), // 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), diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index bda3aee4..c9922bf1 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -21,6 +21,34 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * How long the button must be held before a recording starts. + * + * Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a + * recording, and it also made the control impossible to defend against pointer events it should + * never have seen (see [ArmDelayMs]). + */ +private const val HoldToRecordMs = 220L + +/** + * How long the button ignores presses after entering composition. + * + * The action cluster swaps send out for camera+microphone the instant the field is cleared, which + * puts the microphone exactly where the send button was a frame earlier. Tapping send quickly + * could hand the microphone a pointer-down whose matching pointer-up had already been delivered + * to the send button that no longer exists — leaving the gesture waiting for a release that will + * never come, stuck in "recording" until the composable is disposed. Refusing presses until the + * swap animation has settled removes that whole class of failure. + */ +private const val ArmDelayMs = 350L + +/** Hard cap on a single recording. */ +private const val MaxRecordingMs = 10_000L + +/** Tail kept after release so the last syllable is not clipped. */ +private const val ReleaseTailMs = 500L @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -33,7 +61,13 @@ fun VoiceRecordButton( isRecording: Boolean = false, onStart: () -> Unit, onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit, - onFinish: (filePath: String) -> Unit + onFinish: (filePath: String) -> Unit, + /** + * Invoked whenever a recording ends without producing a file — permission denied, recorder + * failure, or the button being torn down mid-capture. The caller needs this to clear its own + * recording state; without it a failed capture left the composer stuck in recording mode. + */ + onCancel: () -> Unit = {} ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current @@ -51,6 +85,35 @@ fun VoiceRecordButton( val latestOnStart = rememberUpdatedState(onStart) val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) + val latestOnCancel = rememberUpdatedState(onCancel) + + // Set when this instance was composed, so presses inherited from whatever occupied this spot + // beforehand can be rejected. + val composedAt = remember { System.currentTimeMillis() } + + fun buzz() { + try { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } catch (_: Exception) { + } + } + + // Last line of defence: if the button is removed while capturing — the cluster swapping to + // send, the sheet closing, the screen going away — release the recorder and tell the caller, + // so nothing is left holding the microphone or showing a recording UI. + DisposableEffect(Unit) { + onDispose { + ampJob?.cancel() + ampJob = null + if (isCapturing) { + isCapturing = false + runCatching { recorder?.stop() } + recorder = null + recordedFilePath = null + latestOnCancel.value() + } + } + } // Same disc, same sizing and the same press feedback as the camera and send buttons. ComposerActionSurface( @@ -60,63 +123,89 @@ fun VoiceRecordButton( .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isCapturing) { - if (micPermission.status !is PermissionStatus.Granted) { - micPermission.launchPermissionRequest() - return@detectTapGestures - } - val rec = VoiceRecorder(context) - val f = rec.start() - recorder = rec - isCapturing = f != null - recordedFilePath = f?.absolutePath - recordingStart = System.currentTimeMillis() - if (isCapturing) { - latestOnStart.value() - // Haptic "knock" when recording starts - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} - // Start amplitude polling loop - ampJob?.cancel() - ampJob = scope.launch { - while (isActive && isCapturing) { - val amp = recorder?.pollAmplitude() ?: 0 - val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) - latestOnAmplitude.value(amp, elapsedMs) - // Auto-stop after 10 seconds - if (elapsedMs >= 10_000 && isCapturing) { - val file = recorder?.stop() - isCapturing = false - recorder = null - val path = file?.absolutePath - if (!path.isNullOrBlank()) { - // Haptic "knock" on auto stop - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} - latestOnFinish.value(path) - } - break - } - delay(80) + // Guard 1: ignore anything arriving before the swap animation settled. + if (System.currentTimeMillis() - composedAt < ArmDelayMs) { + return@detectTapGestures + } + // Guard 2: never start a second capture on top of a live one. + if (isCapturing) return@detectTapGestures + + if (micPermission.status !is PermissionStatus.Granted) { + micPermission.launchPermissionRequest() + return@detectTapGestures + } + + // Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on + // release and false on cancellation; either way the press was not a hold, + // so nothing should happen. Only a timeout means the finger is still down. + val stillHeld = withTimeoutOrNull(HoldToRecordMs) { + tryAwaitRelease() + } == null + if (!stillHeld) return@detectTapGestures + + val rec = VoiceRecorder(context) + val startedFile = rec.start() + if (startedFile == null) { + // Recorder refused to start; make sure the caller does not sit in a + // recording state that never began. + runCatching { rec.stop() } + latestOnCancel.value() + return@detectTapGestures + } + + recorder = rec + recordedFilePath = startedFile.absolutePath + recordingStart = System.currentTimeMillis() + isCapturing = true + latestOnStart.value() + buzz() + + ampJob?.cancel() + ampJob = scope.launch { + while (isActive && isCapturing) { + val amp = recorder?.pollAmplitude() ?: 0 + val elapsed = + (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L) + latestOnAmplitude.value(amp, elapsed) + + if (elapsed >= MaxRecordingMs && isCapturing) { + val file = recorder?.stop() + isCapturing = false + recorder = null + val path = file?.absolutePath ?: recordedFilePath + recordedFilePath = null + buzz() + // Always report the outcome, even when the file is unusable, + // or the caller stays stuck showing the waveform. + if (!path.isNullOrBlank()) { + latestOnFinish.value(path) + } else { + latestOnCancel.value() } + break } + delay(80) } } + try { - awaitRelease() + tryAwaitRelease() } finally { if (isCapturing) { - // Extend recording for 500ms after release to avoid clipping - delay(500) + // Keep going briefly past the release so the tail is not clipped. + delay(ReleaseTailMs) } if (isCapturing) { val file = recorder?.stop() isCapturing = false recorder = null - val path = (file?.absolutePath ?: recordedFilePath) + val path = file?.absolutePath ?: recordedFilePath recordedFilePath = null + buzz() if (!path.isNullOrBlank()) { - // Haptic "knock" when recording stops - try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {} latestOnFinish.value(path) + } else { + latestOnCancel.value() } } ampJob?.cancel() diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index e7f1a9af..697567b3 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -43,6 +43,16 @@ data class BitchatPalette( val inputOutline: Color, /** Border for a focused text input. A step brighter, still neutral. */ val inputOutlineFocused: Color, + /** + * Fill for text inputs. Near-black / near-white and completely untinted, for the same reason + * as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any + * tint of its own compounds into something muddy. + */ + val inputSurface: Color, + /** Fill for a focused text input. A barely perceptible lift. */ + val inputSurfaceFocused: Color, + /** Resting disc behind the composer's action glyphs. Neutral grey. */ + val inputButton: Color, // MARK: - Text /** Message bodies and row titles. Neutral, not green. */ @@ -77,6 +87,9 @@ val DarkBitchatPalette = BitchatPalette( outlineVariant = Color(0xFF1C271C), inputOutline = Color(0xFF333635), inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), textPrimary = Color(0xFFE8EDE8), textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), @@ -96,6 +109,9 @@ val LightBitchatPalette = BitchatPalette( outlineVariant = Color(0xFFDEE6DE), inputOutline = Color(0xFFCFD3D1), inputOutlineFocused = Color(0xFF8E9490), + inputSurface = Color(0xFFFAFAFA), + inputSurfaceFocused = Color(0xFFF2F2F2), + inputButton = Color(0xFFE8E8E8), textPrimary = Color(0xFF131A13), textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75), From 974de992f0ce226233a6110238632c92e74e77b0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:29:09 +0200 Subject: [PATCH 08/37] notes --- .../java/com/bitchat/android/ui/ChatHeader.kt | 96 ++++++------------- .../bitchat/android/ui/LocationNotesButton.kt | 10 +- 2 files changed, 36 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index c6cb546f..9d831592 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -5,16 +5,9 @@ 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 android.util.Log -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* @@ -39,10 +32,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -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.net.ArtiTorManager +import com.bitchat.android.net.TorMode import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette @@ -103,53 +96,30 @@ private fun HeaderIconButton( } /** - * Connection status indicator. + * Smooth tint for header glyphs that should reflect Tor connection state. * - * Only visible while the connection is still coming up (or has failed). A steady green "all - * good" light is noise: the absence of the indicator already means everything is fine, and the - * eye stops registering an always-on dot anyway. It fades and scales in/out so appearing and - * disappearing reads as a state change rather than a glitch. + * When Tor is off or fully bootstrapped, [normal] is used. While connecting the tint goes + * orange; if Tor is enabled but not running it goes red. Cross-fades so Bootstrap percent + * flips do not flash the whole header. */ @Composable -fun TorStatusDot( - modifier: Modifier = Modifier -) { +internal fun torConnectionTint(normal: Color): Color { val palette = LocalBitchatPalette.current - val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() } - val torStatus by torProvider.statusFlow.collectAsState() + val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState() - val isEnabled = torStatus.mode != com.bitchat.android.net.TorMode.OFF - val isEstablished = torStatus.running && torStatus.bootstrapPercent >= 100 - val isVisible = isEnabled && !isEstablished - - AnimatedVisibility( - visible = isVisible, - enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + - scaleIn( - initialScale = 0.4f, - animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) - ), - exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + - scaleOut( - targetScale = 0.4f, - animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) - ), - ) { - // Bootstrapping vs. failed. Cross-faded, because Tor flips between these frequently and - // a hard colour cut in the corner of the screen reads as a rendering fault. - val dotColor by animateColorAsState( - targetValue = if (torStatus.running) palette.accentOrange else palette.accentRed, - animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), - label = "torDotColor" - ) - Canvas(modifier = modifier) { - drawCircle( - color = dotColor, - radius = size.minDimension / 2, - center = Offset(size.width / 2, size.height / 2) - ) - } + val target = when { + torStatus.mode == TorMode.OFF -> normal + torStatus.running && torStatus.bootstrapPercent >= 100 -> normal + torStatus.running -> palette.accentOrange + else -> palette.accentRed } + + val animated by animateColorAsState( + targetValue = target, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "torConnectionTint" + ) + return animated } @Composable @@ -477,24 +447,14 @@ private fun MainHeader( // MARK: - Status cluster. // - // Order, left to right: connection state, unread DMs, notes, channel, people. - // The connection indicator leads because it is the only item that can invalidate - // everything to its right, and because it appears and disappears on its own — a fixed - // leftmost slot means the rest of the cluster never shifts when it does. + // Order, left to right: unread DMs, notes, channel, people. + // Tor health is read from the location channel / notes icon colour rather than a + // dedicated status dot. Row( verticalAlignment = Alignment.CenterVertically, // Tight, because every child below is its own >=44.dp tap target. horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - // Connection status. The Box holds the slot open at a constant width so the icons to - // its right stay put whether or not the dot is currently showing. - Box( - modifier = Modifier.size(HeaderIconSize), - contentAlignment = Alignment.Center - ) { - TorStatusDot(modifier = Modifier.size(8.dp)) - } - // Unread private messages badge (click to open most recent DM) if (hasUnreadPrivateMessages.isNotEmpty()) { HeaderIconButton( @@ -569,7 +529,9 @@ private fun LocationChannelsButton( // is, so it is plain "mesh". else -> stringResource(R.string.mesh_label) } - val badgeColor = if (isLocation) palette.accentGreen else palette.accentBlue + val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue + // Icon alone carries Tor health so the geohash / "mesh" label stays channel-coloured. + val iconColor = torConnectionTint(normal = channelColor) val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth Row( @@ -585,9 +547,9 @@ private fun LocationChannelsButton( ) { Icon( imageVector = badgeIcon, - contentDescription = null, + contentDescription = stringResource(R.string.cd_tor_status), modifier = Modifier.size(HeaderIconSize), - tint = badgeColor + tint = iconColor ) Text( @@ -595,7 +557,7 @@ private fun LocationChannelsButton( style = MaterialTheme.typography.bodyMedium, fontSize = HeaderTextSize, fontWeight = FontWeight.Medium, - color = badgeColor, + color = channelColor, maxLines = 1 ) } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 185a053e..4819deef 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -26,8 +26,9 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette /** * Location Notes button component for MainHeader - * Shows in mesh mode when location permission granted AND services enabled - * Icon turns primary color when notes exist, gray otherwise + * Shows in mesh mode when location permission granted AND services enabled. + * Base tint is primary when notes exist, secondary otherwise; Tor health may + * override that color (orange connecting, red failed) via [torConnectionTint]. */ @Composable fun LocationNotesButton( @@ -36,6 +37,7 @@ fun LocationNotesButton( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current val context = LocalContext.current // Get channel and permission state @@ -57,6 +59,8 @@ fun LocationNotesButton( if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 val contentDescription = stringResource(R.string.cd_location_notes) + val normalTint = if (hasNotes) colorScheme.primary else palette.textSecondary + val tint = torConnectionTint(normal = normalTint) // Match other header icon buttons: 44.dp target, no Material IconButton min-size padding // that pushed the notes glyph farther from the mesh badge than sibling gaps. Box( @@ -70,7 +74,7 @@ fun LocationNotesButton( imageVector = Icons.Outlined.Description, contentDescription = contentDescription, modifier = Modifier.size(HeaderIconSize), - tint = if (hasNotes) colorScheme.primary else LocalBitchatPalette.current.textSecondary + tint = tint ) } } From 33b060b5517304d0c6cf9ec7cb1c804a4e1a919e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:37:55 +0200 Subject: [PATCH 09/37] nice --- .../android/ui/LocationChannelsSheet.kt | 1083 +++++++++-------- .../bitchat/android/ui/MessageComponents.kt | 216 +++- .../android/ui/MessageArrivalTrackerTest.kt | 141 +++ 3 files changed, 877 insertions(+), 563 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index cc060e47..76b3c020 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -2,22 +2,21 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark -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.filled.Security import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Public 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.clickable 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 @@ -28,35 +27,48 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle 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 androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import com.bitchat.android.geohash.ChannelID -import kotlinx.coroutines.launch -import com.bitchat.android.geohash.GeohashChannel -import com.bitchat.android.geohash.GeohashChannelLevel -import com.bitchat.android.geohash.LocationChannelManager -import com.bitchat.android.geohash.GeohashBookmarksStore -import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.core.ui.component.sheet.BitchatSheetTopBar +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashBookmarksStore +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager 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 +import kotlinx.coroutines.launch /** - * Location Channels Sheet for selecting geohash-based location channels - * Direct port from iOS LocationChannelsSheet for 100% compatibility + * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. + * Selection dots and row icons sit in this column so every option lines up with About settings. + */ +private val ChannelLeadingSlot = 22.dp +private val ChannelLeadingGutter = 16.dp +private val ChannelRowHorizontal = 16.dp +private val ChannelRowVertical = 13.dp +private val ChannelDividerInset = ChannelRowHorizontal + ChannelLeadingSlot + ChannelLeadingGutter +/** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ +private val ChannelSelectedDot = 12.dp + +/** + * Location Channels sheet: grouped card rows matching About → Settings. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,34 +82,22 @@ fun LocationChannelsSheet( val locationManager = LocationChannelManager.getInstance(context) val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } - // Observe location manager state val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() - val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() - val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle() val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle() - // Observe bookmarks state val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle() - - // Observe reactive participant counts val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle() - // UI state var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(null) } - var isInputFocused by remember { mutableStateOf(false) } - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() - // Scroll state for LazyColumn with animated top bar val listState = rememberLazyListState() val isScrolled by remember { derivedStateOf { @@ -105,7 +105,8 @@ fun LocationChannelsSheet( } } val topBarAlpha by animateFloatAsState( - targetValue = if (isScrolled) 0.95f else 0f, + targetValue = if (isScrolled) 0.98f else 0f, + animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing), label = "topBarAlpha" ) @@ -126,6 +127,13 @@ fun LocationChannelsSheet( val standardGreen = palette.accentGreen val standardBlue = palette.accentBlue + val nearbyChannels = remember(availableChannels) { + availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } + } + val showNearbyLoading = nearbyChannels.isEmpty() && + permissionState == LocationChannelManager.PermissionState.AUTHORIZED && + locationServicesEnabled + if (isPresented) { BitchatBottomSheet( modifier = modifier, @@ -136,14 +144,14 @@ fun LocationChannelsSheet( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - // Header Section item(key = "header") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding + 14.dp), + .padding(horizontal = AboutHorizontalPadding), verticalArrangement = Arrangement.spacedBy(10.dp) ) { SheetHeaderBadge(icon = Icons.Outlined.Public) @@ -164,413 +172,268 @@ fun LocationChannelsSheet( } } - // Permission controls if services enabled - if (locationServicesEnabled) { + if (locationServicesEnabled && + permissionState == LocationChannelManager.PermissionState.DENIED + ) { item(key = "permissions") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 8.dp), + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - when (permissionState) { - LocationChannelManager.PermissionState.DENIED -> { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource(R.string.location_permission_denied), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = palette.accentRed - ) - TextButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - } - ) { - Text( - text = stringResource(R.string.open_settings), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace - ) - } - } - } - LocationChannelManager.PermissionState.AUTHORIZED -> { - Text( - text = stringResource(R.string.location_permission_granted), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = standardGreen - ) - } - } - } - } - } - - // Mesh option first - item(key = "mesh") { - ChannelRow( - title = meshTitleWithCount(viewModel), - subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()), - isSelected = selectedChannel is ChannelID.Mesh, - participantCount = meshCount(viewModel), - titleColor = standardBlue, - titleBold = meshCount(viewModel) > 0, - trailingContent = null, - onClick = { - locationManager.select(ChannelID.Mesh) - onDismiss() - } - ) - } - - // Nearby options (only show if location services are enabled) - // CRITICAL: Filter out .building level (precision 8) - iOS pattern - // 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] - val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } - val subtitlePrefix = "#${channel.geohash} • $coverage" - val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 - val highlight = participantCount > 0 - val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) - - ChannelRow( - title = geohashTitleWithCount(channel, participantCount), - subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), - isSelected = isChannelSelected(channel, selectedChannel), - participantCount = participantCount, - 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 = if (isBookmarked) palette.accentGreen else palette.textSecondary, - modifier = Modifier.size(22.dp) - ) - } - }, - onClick = { - // Selecting a suggested nearby channel is not a teleport - locationManager.setTeleported(false) - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } - ) - } - } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = palette.textSecondary - ) Text( - text = stringResource(R.string.finding_nearby_channels), + text = stringResource(R.string.location_permission_denied), fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = palette.textSecondary + color = palette.accentRed ) - } - } - } - - // Bookmarked geohashes - if (bookmarks.isNotEmpty()) { - item(key = "bookmarked_header") { - SheetSectionLabel(text = stringResource(R.string.bookmarked)) - } - items(bookmarks) { gh -> - val level = levelForLength(gh.length) - val channel = GeohashChannel(level = level, geohash = gh) - val coverage = coverageString(gh.length) - val subtitlePrefix = "#${gh} • $coverage" - val name = bookmarkNames[gh] - val subtitle = subtitlePrefix + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") - val participantCount = geohashParticipantCounts[gh] ?: 0 - val title = geohashHashTitleWithCount(gh, participantCount) - - ChannelRow( - title = title, - subtitle = subtitle, - isSelected = isChannelSelected(channel, selectedChannel), - participantCount = participantCount, - titleColor = null, - titleBold = participantCount > 0, - trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(gh) }) { - Icon( - imageVector = Icons.Filled.Bookmark, - contentDescription = stringResource(R.string.cd_remove_bookmark), - tint = palette.accentGreen, - modifier = Modifier.size(22.dp) - ) - } - }, - onClick = { - // For bookmarked selection, mark teleported based on regional membership - val inRegional = availableChannels.any { it.geohash == gh } - if (!inRegional && availableChannels.isNotEmpty()) { - locationManager.setTeleported(true) - } else { - locationManager.setTeleported(false) - } - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } - ) - LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } - } - } - - // Custom geohash teleport (iOS-style inline form) - item(key = "custom_geohash") { - Surface( - color = palette.surface, - shape = RoundedCornerShape(10.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(start = 14.dp, end = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.hash_symbol), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = palette.textTertiary - ) - - BasicTextField( - value = customGeohash, - onValueChange = { newValue -> - // iOS-style geohash validation (base32 characters only) - val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() - val filtered = newValue - .lowercase() - .replace("#", "") - .filter { it in allowed } - .take(12) - - customGeohash = filtered - customError = null - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.primary - ), - cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), - modifier = Modifier - .weight(1f) - .onFocusChanged { focusState -> - isInputFocused = focusState.isFocused - if (focusState.isFocused) { - coroutineScope.launch { - sheetState.expand() - // Scroll to bottom to show input and remove button - listState.animateScrollToItem( - index = listState.layoutInfo.totalItemsCount - 1 - ) - } - } - }, - singleLine = true, - decorationBox = { innerTextField -> - if (customGeohash.isEmpty()) { - Text( - text = stringResource(R.string.geohash_placeholder), - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = palette.textTertiary - ) + TextButton( + onClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) } - innerTextField() - } - ) - - val normalized = customGeohash.trim().lowercase().replace("#", "") - - // Map picker button - IconButton(onClick = { - val initial = when { - normalized.isNotBlank() -> normalized - selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash - else -> "" - } - val intent = Intent(context, GeohashPickerActivity::class.java).apply { - putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) - } - mapPickerLauncher.launch(intent) - }) { - Icon( - imageVector = Icons.Filled.Map, - contentDescription = stringResource(R.string.cd_open_map), - tint = palette.textSecondary, - modifier = Modifier.size(22.dp) + context.startActivity(intent) + }, + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = stringResource(R.string.open_settings), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace ) } + } + } + } - val isValid = validateGeohash(normalized) - - // 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) - val channel = GeohashChannel(level = level, geohash = normalized) - // Mark this selection as a manual teleport - locationManager.setTeleported(true) - locationManager.select(ChannelID.Location(channel)) + // Channels card: mesh + nearby + teleport input + item(key = "channels_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.channels)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + ChannelOptionRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + R.string.location_bluetooth_subtitle, + bluetoothRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) onDismiss() - } else { - customError = context.getString(R.string.invalid_geohash) } - }, - enabled = isValid, - shape = RoundedCornerShape(8.dp), - color = palette.surfaceVariant + ) + + if (locationServicesEnabled) { + if (nearbyChannels.isNotEmpty()) { + nearbyChannels.forEach { channel -> + ChannelCardDivider() + val coverage = coverageString(channel.geohash.length) + val nameBase = locationNames[channel.level] + val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } + val subtitlePrefix = "#${channel.geohash} • $coverage" + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + ChannelOptionRow( + title = geohashTitleWithCount(channel, participantCount), + subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { bookmarksStore.toggle(channel.geohash) } + ) + }, + onClick = { + locationManager.setTeleported(false) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + } + } else if (showNearbyLoading) { + ChannelCardDivider() + ChannelLoadingRow() + } + } + + ChannelCardDivider() + CustomGeohashRow( + customGeohash = customGeohash, + onGeohashChange = { value -> + val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() + customGeohash = value + .lowercase() + .replace("#", "") + .filter { it in allowed } + .take(12) + customError = null + }, + onFocusGained = { + coroutineScope.launch { + sheetState.expand() + listState.animateScrollToItem( + index = listState.layoutInfo.totalItemsCount - 1 + ) + } + }, + onOpenMap = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + val initial = when { + normalized.isNotBlank() -> normalized + selectedChannel is ChannelID.Location -> + (selectedChannel as ChannelID.Location).channel.geohash + else -> "" + } + val intent = Intent(context, GeohashPickerActivity::class.java).apply { + putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) + } + mapPickerLauncher.launch(intent) + }, + onTeleport = { + val normalized = customGeohash.trim().lowercase().replace("#", "") + if (validateGeohash(normalized)) { + val level = levelForLength(normalized.length) + val channel = GeohashChannel(level = level, geohash = normalized) + locationManager.setTeleported(true) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } else { + customError = context.getString(R.string.invalid_geohash) + } + } + ) + } + } + + if (customError != null) { + Text( + text = customError!!, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = palette.accentRed, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) + ) + } + } + } + + if (bookmarks.isNotEmpty()) { + item(key = "bookmarks_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.bookmarked)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { - Row( - modifier = Modifier - .height(32.dp) - .padding(horizontal = 10.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.PinDrop, - contentDescription = stringResource(R.string.cd_teleport), - modifier = Modifier.size(14.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 - ) + Column { + bookmarks.forEachIndexed { index, gh -> + if (index > 0) ChannelCardDivider() + val level = levelForLength(gh.length) + val channel = GeohashChannel(level = level, geohash = gh) + val coverage = coverageString(gh.length) + val name = bookmarkNames[gh] + val subtitle = "#$gh • $coverage" + + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[gh] ?: 0 + + ChannelOptionRow( + title = geohashHashTitleWithCount(gh, participantCount), + subtitle = subtitle, + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = true, + onClick = { bookmarksStore.toggle(gh) } + ) + }, + onClick = { + val inRegional = availableChannels.any { it.geohash == gh } + locationManager.setTeleported( + !inRegional && availableChannels.isNotEmpty() + ) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } + } } } } } } - // Error message for custom geohash - if (customError != null) { - item(key = "geohash_error") { - Text( - text = customError!!, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = palette.accentRed, - modifier = Modifier - .fillMaxWidth() - .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( + Column { + AboutSectionLabel(text = stringResource(R.string.about_network)) + Surface( modifier = Modifier .fillMaxWidth() - .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape ) { - 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( + ChannelSettingsToggleRow( + icon = Icons.Filled.Security, + title = stringResource(R.string.location_tor_routing_title), + subtitle = stringResource(R.string.location_tor_routing_desc), checked = torMode == TorMode.ON, + enabled = torAvailable, + statusIndicator = { + BitchatBadge(text = stringResource(R.string.badge_recommended)) + }, 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 + } + ) + } + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp ) ) } } } - // Location services toggle button item(key = "location_toggle") { SheetDestructiveButton( text = if (locationServicesEnabled) { @@ -587,15 +450,14 @@ fun LocationChannelsSheet( } }, modifier = Modifier.padding( - start = SheetHorizontalPadding, - end = SheetHorizontalPadding, - top = 16.dp + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp ) ) } } - // TopBar (animated) BitchatSheetTopBar( onClose = onDismiss, modifier = modifier.align(Alignment.TopCenter), @@ -609,19 +471,17 @@ fun LocationChannelsSheet( } } - // Lifecycle management: when presented, manage location updates DisposableEffect(isPresented, permissionState, locationServicesEnabled) { - if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { + if (isPresented && + permissionState == LocationChannelManager.PermissionState.AUTHORIZED && + locationServicesEnabled + ) { locationManager.refreshChannels() locationManager.beginLiveRefresh() } - - onDispose { - locationManager.endLiveRefresh() - } + onDispose { locationManager.endLiveRefresh() } } - // Sampling management: update sampling when channels/bookmarks change LaunchedEffect(isPresented, availableChannels, bookmarks) { if (isPresented) { val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() @@ -631,20 +491,30 @@ fun LocationChannelsSheet( } } - // Ensure cleanup when the composable is destroyed (e.g. removed from parent composition) DisposableEffect(Unit) { - onDispose { - viewModel.endGeohashSampling() - } + onDispose { viewModel.endGeohashSampling() } } } @Composable -private fun ChannelRow( +private fun ChannelCardDivider() { + val palette = LocalBitchatPalette.current + HorizontalDivider( + modifier = Modifier.padding(start = ChannelDividerInset), + thickness = 1.dp, + color = palette.outlineVariant + ) +} + +/** + * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. + * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). + */ +@Composable +private fun ChannelOptionRow( title: String, subtitle: String, isSelected: Boolean, - /** Drives the count animation; the number itself is already baked into [title]. */ participantCount: Int, titleColor: Color? = null, titleBold: Boolean = false, @@ -652,95 +522,301 @@ private fun ChannelRow( onClick: () -> Unit ) { val palette = LocalBitchatPalette.current + val (baseTitle, countSuffix) = splitTitleAndCount(title) - // 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 = containerColor, - shape = RoundedCornerShape(10.dp), + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .clickable(onClick = onClick) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center ) { if (isSelected) { Box( modifier = Modifier - .padding(end = 10.dp) - .size(6.dp) + .size(ChannelSelectedDot) .background(palette.accentGreen, CircleShape) ) } + } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Split title to handle count part with smaller font (iOS style) - val (baseTitle, countSuffix) = splitTitleAndCount(title) - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = baseTitle, - fontSize = 14.sp, - fontFamily = FontFamily.Monospace, - fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, - color = titleColor ?: palette.textPrimary - ) - - countSuffix?.let { count -> - AnimatedCountLabel( - // Participant counts are polled from the relays, so they change - // while the sheet is open. - count = participantCount, - text = count, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = palette.textSecondary - ) - } - } - Text( - text = subtitle, - fontSize = 11.sp, + text = baseTitle, + fontSize = 14.sp, fontFamily = FontFamily.Monospace, - color = palette.textSecondary + fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, + color = titleColor ?: palette.textPrimary ) + countSuffix?.let { count -> + AnimatedCountLabel( + count = participantCount, + text = count, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } } + Text( + text = subtitle, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + lineHeight = 17.sp, + color = palette.textSecondary + ) + } - if (trailingContent != null) { - trailingContent() - } + if (trailingContent != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailingContent() } } } -// MARK: - Helper Functions (matching iOS implementation) +@Composable +private fun ChannelBookmarkButton( + bookmarked: Boolean, + onClick: () -> Unit +) { + val palette = LocalBitchatPalette.current + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (bookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource( + if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark + ), + tint = if (bookmarked) palette.accentGreen else palette.textSecondary, + modifier = Modifier.size(22.dp) + ) + } +} + +@Composable +private fun ChannelLoadingRow() { + val palette = LocalBitchatPalette.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + horizontalArrangement = Arrangement.spacedBy(ChannelLeadingGutter), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = palette.textSecondary + ) + } + Text( + text = stringResource(R.string.finding_nearby_channels), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } +} + +/** + * Teleport / custom geohash control unified into the same card as channel options. + */ +@Composable +private fun CustomGeohashRow( + customGeohash: String, + onGeohashChange: (String) -> Unit, + onFocusGained: () -> Unit, + onOpenMap: () -> Unit, + onTeleport: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + val normalized = customGeohash.trim().lowercase().replace("#", "") + val isValid = validateGeohash(normalized) + val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PinDrop, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + + Text( + text = stringResource(R.string.hash_symbol), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = palette.textTertiary + ) + + Spacer(modifier = Modifier.width(4.dp)) + + BasicTextField( + value = customGeohash, + onValueChange = onGeohashChange, + textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.primary + ), + cursorBrush = SolidColor(colorScheme.primary), + singleLine = true, + modifier = Modifier + .weight(1f) + .onFocusChanged { if (it.isFocused) onFocusGained() }, + decorationBox = { inner -> + if (customGeohash.isEmpty()) { + Text( + text = stringResource(R.string.geohash_placeholder), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = palette.textTertiary + ) + } + inner() + } + ) + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onOpenMap), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = stringResource(R.string.cd_open_map), + tint = palette.textSecondary, + modifier = Modifier.size(22.dp) + ) + } + + Surface( + onClick = onTeleport, + enabled = isValid, + shape = RoundedCornerShape(8.dp), + color = palette.surfaceVariant + ) { + Text( + text = stringResource(R.string.teleport).uppercase(), + fontSize = 11.sp, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + color = teleportColor, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) + ) + } + } +} + +/** Settings-toggle row geometry (icon + title/subtitle + switch), local copy for this sheet. */ +@Composable +private fun ChannelSettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) colorScheme.primary else palette.textTertiary, + modifier = Modifier.size(ChannelLeadingSlot) + ) + Spacer(modifier = Modifier.width(ChannelLeadingGutter)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (enabled) palette.textPrimary else palette.textTertiary + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 17.sp, + color = if (enabled) palette.textSecondary else palette.textTertiary + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = palette.accentGreen, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = palette.surfaceVariant + ) + ) + } +} + +// MARK: - Helper Functions private fun splitTitleAndCount(title: String): Pair { val lastBracketIndex = title.lastIndexOf('[') return if (lastBracketIndex != -1) { - val prefix = title.substring(0, lastBracketIndex).trim() - val suffix = title.substring(lastBracketIndex) - Pair(prefix, suffix) + Pair(title.substring(0, lastBracketIndex).trim(), title.substring(lastBracketIndex)) } else { Pair(title, null) } @@ -749,55 +825,47 @@ private fun splitTitleAndCount(title: String): Pair { @Composable private fun meshTitleWithCount(viewModel: ChatViewModel): String { val meshCount = meshCount(viewModel) - val ctx = androidx.compose.ui.platform.LocalContext.current - val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount) - val meshLabel = stringResource(com.bitchat.android.R.string.mesh_title) + val ctx = LocalContext.current + val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount) + val meshLabel = stringResource(R.string.mesh_title) return "$meshLabel [$peopleText]" } private fun meshCount(viewModel: ChatViewModel): Int { val myID = viewModel.myPeerID - return viewModel.connectedPeers.value?.count { peerID -> - peerID != myID - } ?: 0 + return viewModel.connectedPeers.value?.count { it != myID } ?: 0 } @Composable private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String { - val ctx = androidx.compose.ui.platform.LocalContext.current - - // For high precision channels (Neighborhood, Block) where we don't broadcast presence, - // show "? people" instead of "0 people" to avoid misleading "nobody is here" indication. + val ctx = LocalContext.current val isHighPrecision = channel.level.precision > 5 val peopleText = if (isHighPrecision && participantCount == 0) { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") } else { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - val levelName = when (channel.level) { - com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes - com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block) - com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood) - com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city) - com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE -> stringResource(com.bitchat.android.R.string.location_level_province) - com.bitchat.android.geohash.GeohashChannelLevel.REGION -> stringResource(com.bitchat.android.R.string.location_level_region) + GeohashChannelLevel.BUILDING -> "Building" + GeohashChannelLevel.BLOCK -> stringResource(R.string.location_level_block) + GeohashChannelLevel.NEIGHBORHOOD -> stringResource(R.string.location_level_neighborhood) + GeohashChannelLevel.CITY -> stringResource(R.string.location_level_city) + GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province) + GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region) } return "$levelName [$peopleText]" } @Composable private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String { - val ctx = androidx.compose.ui.platform.LocalContext.current + val ctx = LocalContext.current val level = levelForLength(geohash.length) val isHighPrecision = level.precision > 5 - val peopleText = if (isHighPrecision && participantCount == 0) { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?") + ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?") } else { - ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount) + ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - return "#$geohash [$peopleText]" } @@ -821,13 +889,12 @@ private fun levelForLength(length: Int): GeohashChannelLevel { 5 -> GeohashChannelLevel.CITY 6 -> GeohashChannelLevel.NEIGHBORHOOD 7 -> GeohashChannelLevel.BLOCK - 8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level + 8 -> GeohashChannelLevel.BUILDING else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK } } private fun coverageString(precision: Int): String { - // Approximate max cell dimension at equator for a given geohash length val maxMeters = when (precision) { 2 -> 1_250_000.0 3 -> 156_000.0 @@ -840,10 +907,7 @@ private fun coverageString(precision: Int): String { 10 -> 1.19 else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble()) } - - // Use metric system for simplicity (could be made locale-aware) - val km = maxMeters / 1000.0 - return "~${formatDistance(km)} km" + return "~${formatDistance(maxMeters / 1000.0)} km" } private fun formatDistance(value: Double): String { @@ -854,11 +918,6 @@ private fun formatDistance(value: Double): String { } } -private fun bluetoothRangeString(): String { - // Approximate Bluetooth LE range for typical mobile devices - return "~10–50 m" -} +private fun bluetoothRangeString(): String = "~10–50 m" -private fun formattedNamePrefix(level: GeohashChannelLevel): String { - return "~" -} +private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~" diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index ad763aa7..aac25e91 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -3,6 +3,17 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -35,6 +46,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext @@ -45,6 +57,7 @@ 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.R @@ -55,6 +68,7 @@ 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.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle @@ -69,6 +83,82 @@ import java.util.Locale * Extracted from ChatScreen.kt for better organization */ +/** How far a newly arrived message travels up into place. */ +private val MessageEntrySlide = 14.dp + +/** + * Entry motion for a new message: quick, with just enough damping to settle rather than snap. + * Runs entirely on a graphics layer, so it costs a transform and nothing else. + */ +private val MessageEntrySpec: AnimationSpec = + spring(dampingRatio = 0.85f, stiffness = 1200f) + +/** + * Motion for messages being pushed out of the way by an arrival. Softer than the entry so the + * conversation glides up while the new message itself lands crisply. + */ +private val MessagePlacementSpec: FiniteAnimationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold +) + +/** Removals are not worth dwelling on. */ +private val MessageFadeOutSpec: FiniteAnimationSpec = tween(BitchatMotion.QUICK_MS) + +/** + * Above this many simultaneous arrivals, entry animations are skipped. + * + * A history sync or a channel switch can append hundreds of messages in one frame. Animating each + * would spend the entire frame budget on motion nobody asked to see, so a burst is adopted + * silently and only conversational-pace arrivals animate. + */ +internal const val MaxAnimatedArrivals = 6 + +/** + * Remembers which message ids have already been seen, so genuine arrivals can be told apart from + * items merely scrolling back into view. + * + * This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items + * on demand, so animating on first composition would replay the animation for every old message + * the user scrolled back to. + */ +internal class MessageArrivalTracker { + val known = HashSet() + var seeded = false +} + +/** + * Ids that should animate in on this composition pass. + * + * Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after* + * the frame's composition, by which point a new message's item has already composed and would + * have missed its cue. + */ +internal fun MessageArrivalTracker.arrivals(messages: List): Set { + if (!seeded) { + // First load adopts everything silently. A whole screenful animating on open reads as a + // glitch, not a flourish. + messages.forEach { known.add(it.id) } + seeded = true + return emptySet() + } + + // `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass. + val added = messages.filter { known.add(it.id) } + + if (known.size > messages.size) { + // Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot + // grow without bound and so re-added messages animate again. + known.retainAll(messages.mapTo(HashSet(messages.size)) { it.id }) + } + + return when { + added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet() + else -> added.mapTo(HashSet(added.size)) { it.id } + } +} + @Composable fun MessagesList( messages: List, @@ -129,6 +219,13 @@ fun MessagesList( } } + // Recomputed only when the list actually gains or loses a message, and synchronously, so the + // arriving item can read its cue during the same composition pass in which it first appears. + val arrivalTracker = remember { MessageArrivalTracker() } + val enteringIds = remember(messages.size, messages.lastOrNull()?.id) { + arrivalTracker.arrivals(messages) + } + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, @@ -158,6 +255,24 @@ fun MessagesList( val previous = messages.getOrNull(originalIndex - 1) val isGrouped = MessageGrouping.shouldGroup(previous, message) + // Decided once per item instance, so an item recycling back into view during a scroll + // never re-animates. Items that are not arriving skip the animation machinery + // entirely: no Animatable, no coroutine, and no extra render layer per row. + val isArriving = remember(message.id) { message.id in enteringIds } + val entryModifier = if (isArriving) { + val entry = remember(message.id) { Animatable(0f) } + LaunchedEffect(message.id) { entry.animateTo(1f, MessageEntrySpec) } + // A draw-time transform only: no measure, no layout, and no recomposition of the + // message content on any frame of the animation. + Modifier.graphicsLayer { + val progress = entry.value + alpha = progress + translationY = (1f - progress) * MessageEntrySlide.toPx() + } + } else { + Modifier + } + MessageItem( message = message, messages = messages, @@ -171,7 +286,18 @@ fun MessagesList( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, - onImageClick = onImageClick + onImageClick = onImageClick, + modifier = Modifier + // Animates the shift when a neighbour is inserted or removed: this is what + // makes the conversation glide up instead of jumping. + .animateItem( + // Entry fade is handled by entryModifier, together with the slide, so the + // two cannot drift out of step. + fadeInSpec = null, + placementSpec = MessagePlacementSpec, + fadeOutSpec = MessageFadeOutSpec + ) + .then(entryModifier) ) } } @@ -189,13 +315,14 @@ fun MessageItem( onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, - onImageClick: ((String, List, Int) -> Unit)? = null + onImageClick: ((String, List, Int) -> Unit)? = null, + modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } - + Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) @@ -557,53 +684,40 @@ internal fun TextMessageLayout( fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - - when (status) { - is DeliveryStatus.Sending -> { - Text( - text = stringResource(R.string.status_sending), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) - } - is DeliveryStatus.Sent -> { - // Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity) - Text( - text = stringResource(R.string.status_pending), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) - } - is DeliveryStatus.Delivered -> { - // Single check for Delivered (matches iOS expectations) - Text( - text = stringResource(R.string.status_sent), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.8f) - ) - } - is DeliveryStatus.Read -> { - Text( - text = stringResource(R.string.status_delivered), - fontSize = 10.sp, - color = palette.accentBlue, - fontWeight = FontWeight.Bold - ) - } - is DeliveryStatus.Failed -> { - Text( - text = stringResource(R.string.status_failed), - fontSize = 10.sp, - color = palette.accentRed - ) - } - is DeliveryStatus.PartiallyDelivered -> { - // Show a single subdued check without numeric label - Text( - text = stringResource(R.string.status_sent), - fontSize = 10.sp, - color = colorScheme.primary.copy(alpha = 0.6f) - ) + + // Status advances on its own as acks come back, so a hard glyph swap reads as a flicker. + // Keyed on the status *type* rather than the instance, because Delivered/Read carry a + // timestamp that would otherwise retrigger the transition on every identical update. + AnimatedContent( + targetState = status::class, + transitionSpec = { + fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith + fadeOut(tween(BitchatMotion.QUICK_MS)) + }, + label = "deliveryStatus" + ) { statusClass -> + val (text, color, weight) = when (statusClass) { + DeliveryStatus.Sending::class -> + Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) + // Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity). + DeliveryStatus.Sent::class -> + Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) + DeliveryStatus.Delivered::class -> + Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal) + DeliveryStatus.Read::class -> + Triple(R.string.status_delivered, palette.accentBlue, FontWeight.Bold) + DeliveryStatus.Failed::class -> + Triple(R.string.status_failed, palette.accentRed, FontWeight.Normal) + // A single subdued check, without the numeric label. + else -> + Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) } + + Text( + text = stringResource(text), + fontSize = 10.sp, + color = color, + fontWeight = weight + ) } } diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt new file mode 100644 index 00000000..0c848281 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -0,0 +1,141 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The arrival tracker decides which messages animate in. + * + * Getting it wrong is not subtle: animate too eagerly and every old message replays its entrance + * whenever `LazyColumn` recycles it back into view, which looks broken during a scroll. + */ +class MessageArrivalTrackerTest { + + private var seq = 0 + + private fun msg(id: String = "m${seq++}") = BitchatMessage( + id = id, + sender = "alice", + content = "hello", + timestamp = Date(0), + ) + + @Test + fun `first load adopts everything silently`() { + val tracker = MessageArrivalTracker() + val initial = listOf(msg("a"), msg("b"), msg("c")) + + assertTrue( + "a whole screenful animating on open reads as a glitch", + tracker.arrivals(initial).isEmpty() + ) + } + + @Test + fun `a message appended after the first load animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + messages += msg("c") + assertEquals(setOf("c"), tracker.arrivals(messages)) + } + + @Test + fun `an empty first load still lets the first real message animate`() { + // A brand new conversation seeds with nothing, so its opening message is a genuine arrival. + val tracker = MessageArrivalTracker() + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `re-presenting the same list animates nothing`() { + val tracker = MessageArrivalTracker() + val messages = listOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `an already-seen message never animates again`() { + // Stands in for LazyColumn recycling an old row back into view mid-scroll. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + messages += msg("b") + tracker.arrivals(messages) + + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `only the new messages in a batch animate`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a")) + tracker.arrivals(messages) + + messages += listOf(msg("b"), msg("c")) + assertEquals(setOf("b", "c"), tracker.arrivals(messages)) + } + + @Test + fun `a burst larger than the cap is adopted silently`() { + // History sync: animating hundreds of rows would spend the whole frame budget on motion + // nobody asked to see. + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals + 1) { messages += msg("burst$it") } + assertTrue(tracker.arrivals(messages).isEmpty()) + } + + @Test + fun `a burst exactly at the cap still animates`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("seed")) + tracker.arrivals(messages) + + repeat(MaxAnimatedArrivals) { messages += msg("b$it") } + assertEquals(MaxAnimatedArrivals, tracker.arrivals(messages).size) + } + + @Test + fun `clearing the conversation prunes the known set`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b"), msg("c")) + tracker.arrivals(messages) + + tracker.arrivals(emptyList()) + assertTrue("stale ids would leak and grow without bound", tracker.known.isEmpty()) + } + + @Test + fun `a message re-added after a clear animates again`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + tracker.arrivals(emptyList()) + + assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) + } + + @Test + fun `the known set never outgrows the conversation`() { + val tracker = MessageArrivalTracker() + val messages = mutableListOf(msg("a"), msg("b")) + tracker.arrivals(messages) + + // Simulate a channel switch: entirely different messages, same list size. + val switched = listOf(msg("x"), msg("y")) + tracker.arrivals(switched) + + assertEquals(setOf("x", "y"), tracker.known) + } +} From 521784b3594470948424470c389f41e02a01b930 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:44:13 +0200 Subject: [PATCH 10/37] nicer --- .../android/ui/LocationChannelsSheet.kt | 173 ++++++++++++------ app/src/main/res/values/strings.xml | 5 +- 2 files changed, 120 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 76b3c020..43038419 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material.icons.filled.Security @@ -147,31 +148,6 @@ fun LocationChannelsSheet( contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(0.dp) ) { - item(key = "header") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - 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 - ) - } - } - if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.DENIED ) { @@ -180,7 +156,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) - .padding(top = 16.dp), + .padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -208,38 +184,64 @@ fun LocationChannelsSheet( } } - // Channels card: mesh + nearby + teleport input - item(key = "channels_card") { + // Mesh section: icon + title header, offline subtitle, then selection card + item(key = "mesh_card") { Column { - AboutSectionLabel(text = stringResource(R.string.channels)) + ChannelSectionHeader( + icon = Icons.Filled.Hub, + title = stringResource(R.string.mesh_title), + subtitle = stringResource(R.string.mesh_section_subtitle), + modifier = Modifier.padding(top = 8.dp) + ) Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + R.string.location_bluetooth_subtitle, + bluetoothRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) + onDismiss() + } + ) + } + } + } + + // Location channels: globe + title, geohash subtitle, nearby levels + teleport + item(key = "channels_card") { + Column { + ChannelSectionHeader( + icon = Icons.Outlined.Public, + title = stringResource(R.string.location_channels_heading), + subtitle = stringResource(R.string.location_channels_desc), + modifier = Modifier.padding(top = 20.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), color = palette.surface, shape = AboutCardShape ) { Column { - ChannelOptionRow( - title = meshTitleWithCount(viewModel), - subtitle = stringResource( - R.string.location_bluetooth_subtitle, - bluetoothRangeString() - ), - isSelected = selectedChannel is ChannelID.Mesh, - participantCount = meshCount(viewModel), - titleColor = standardBlue, - titleBold = meshCount(viewModel) > 0, - onClick = { - locationManager.select(ChannelID.Mesh) - onDismiss() - } - ) - if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { - nearbyChannels.forEach { channel -> - ChannelCardDivider() + nearbyChannels.forEachIndexed { index, channel -> + if (index > 0) ChannelCardDivider() val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } @@ -267,13 +269,13 @@ fun LocationChannelsSheet( } ) } - } else if (showNearbyLoading) { ChannelCardDivider() + } else if (showNearbyLoading) { ChannelLoadingRow() + ChannelCardDivider() } } - ChannelCardDivider() CustomGeohashRow( customGeohash = customGeohash, onGeohashChange = { value -> @@ -506,6 +508,53 @@ private fun ChannelCardDivider() { ) } +/** + * Section title row: icon + name on one line, optional short subtitle beneath. + */ +@Composable +private fun ChannelSectionHeader( + icon: ImageVector, + title: String, + subtitle: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } +} + /** * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). @@ -518,9 +567,11 @@ private fun ChannelOptionRow( participantCount: Int, titleColor: Color? = null, titleBold: Boolean = false, + leadingIcon: ImageVector? = null, trailingContent: (@Composable (() -> Unit))? = null, onClick: () -> Unit ) { + val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current val (baseTitle, countSuffix) = splitTitleAndCount(title) @@ -535,12 +586,22 @@ private fun ChannelOptionRow( modifier = Modifier.size(ChannelLeadingSlot), contentAlignment = Alignment.Center ) { - if (isSelected) { - Box( - modifier = Modifier - .size(ChannelSelectedDot) - .background(palette.accentGreen, CircleShape) - ) + when { + isSelected -> { + Box( + modifier = Modifier + .size(ChannelSelectedDot) + .background(palette.accentGreen, CircleShape) + ) + } + leadingIcon != null -> { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b7a7022..d0987249 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -241,9 +241,10 @@ - #Location Channels + Channels Location Channels - 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. + Chat nearby over geohash channels. Only a coarse location is shared, never exact GPS. + Offline mesh via Bluetooth. No internet required. Nearby From 3dbe9c1c52412ddf3e5163b7adea7c65c89bdc2f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:50:46 +0200 Subject: [PATCH 11/37] lists --- .../com/bitchat/android/ui/AboutSections.kt | 69 +++ .../bitchat/android/ui/GeohashPeopleList.kt | 217 +++++---- .../android/ui/LocationChannelsSheet.kt | 81 +--- .../bitchat/android/ui/MeshPeerListSheet.kt | 453 +++++++++--------- 4 files changed, 423 insertions(+), 397 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index af17a509..8dfb63fb 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -68,6 +68,15 @@ internal val AboutHorizontalPadding = 20.dp /** Card corner radius for grouped rows. */ internal val AboutCardShape = RoundedCornerShape(16.dp) +/** Leading icon column in settings-style sheet rows. */ +internal val SheetRowLeadingSlot = 22.dp +internal val SheetRowLeadingGutter = 16.dp +internal val SheetRowHorizontal = 16.dp +internal val SheetRowVertical = 13.dp +internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter +/** Selection indicator sized for [SheetRowLeadingSlot]. */ +internal val SheetRowSelectedDot = 12.dp + /** * Two top-level views of the sheet: what the app is and how to drive it, versus the knobs. */ @@ -99,6 +108,66 @@ internal fun AboutSectionLabel( ) } +/** + * Icon + title on one line, optional short subtitle beneath. Used by location / network sheets. + */ +@Composable +internal fun SheetIconSectionHeader( + icon: ImageVector, + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val palette = LocalBitchatPalette.current + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = FontFamily.Monospace, + color = palette.textSecondary + ) + } + } +} + +/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ +@Composable +internal fun SheetCardDivider() { + val palette = LocalBitchatPalette.current + HorizontalDivider( + modifier = Modifier.padding(start = SheetRowDividerInset), + thickness = 1.dp, + color = palette.outlineVariant + ) +} + /** * Centered app identity block: logo, wordmark, tagline, version. */ diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index cb3cc476..b29c12bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,13 +1,16 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Public import android.util.Log -import androidx.compose.foundation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -17,25 +20,20 @@ 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.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 import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.ui.theme.LocalBitchatPalette +import java.util.* /** - * GeohashPeopleList - iOS-compatible component for displaying geohash participants - * Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers + * Geohash people list — card groups matching location / settings sheet rows. */ -/** - * GeoPerson data class - matches iOS GeoPerson structure exactly - */ data class GeoPerson( val id: String, // pubkey hex (lowercased) - matches iOS - val displayName: String, // nickname with #suffix - matches iOS + val displayName: String, // nickname with #suffix - matches iOS val lastSeen: Date // activity timestamp - matches iOS ) @@ -46,32 +44,41 @@ fun GeohashPeopleList( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - - // Observe geohash people from ChatViewModel + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() - + 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), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = palette.textTertiary, - modifier = Modifier.padding( - horizontal = SheetHorizontalPadding + 14.dp, - vertical = 12.dp - ) + SheetIconSectionHeader( + icon = Icons.Outlined.LocationOn, + title = stringResource(R.string.section_on_location) ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Text( + text = stringResource(R.string.nobody_around), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ) + ) + } } else { - // Get current geohash identity for "me" detection val myHex = remember(selectedLocationChannel) { when (val channel = selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { @@ -89,30 +96,26 @@ fun GeohashPeopleList( else -> null } } - - // Sort people: me first, then by lastSeen (matches iOS exactly) + val orderedPeople = remember(geohashPeople, myHex) { geohashPeople.sortedWith { a, b -> when { myHex != null && a.id == myHex && b.id != myHex -> -1 myHex != null && b.id == myHex && a.id != myHex -> 1 - else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first + else -> b.lastSeen.compareTo(a.lastSeen) } } } - // Compute base name collisions to decide whether to show hash suffix val baseNameCounts = remember(geohashPeople) { val counts = mutableMapOf() geohashPeople.forEach { person -> - val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName) + val (b, _) = splitSuffix(person.displayName) counts[b] = (counts[b] ?: 0) + 1 } counts } - - // 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 { @@ -132,11 +135,9 @@ fun GeohashPeopleList( nickname = nickname, colorScheme = colorScheme, viewModel = viewModel, - showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1, + showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1, onTap = { if (person.id != myHex) { - // TODO: Re-enable when NIP-17 geohash DM issues are fixed - // Start geohash DM (iOS-compatible) viewModel.startGeohashDM(person.id) onTapPerson() } @@ -145,13 +146,48 @@ fun GeohashPeopleList( } if (localPeople.isNotEmpty()) { - SheetSectionLabel(text = stringResource(R.string.section_on_location)) - localPeople.forEach { personRow(it) } + SheetIconSectionHeader( + icon = Icons.Outlined.LocationOn, + title = stringResource(R.string.section_on_location) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + localPeople.forEachIndexed { index, person -> + if (index > 0) SheetCardDivider() + personRow(person) + } + } + } } if (teleportedPeople.isNotEmpty()) { - SheetSectionLabel(text = stringResource(R.string.section_teleported_in)) - teleportedPeople.forEach { personRow(it) } + SheetIconSectionHeader( + icon = Icons.Outlined.Explore, + title = stringResource(R.string.section_teleported_in), + modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + teleportedPeople.forEachIndexed { index, person -> + if (index > 0) SheetCardDivider() + personRow(person) + } + } + } } } } @@ -172,69 +208,57 @@ private fun GeohashPersonItem( ) { val palette = LocalBitchatPalette.current - Surface( - onClick = onTap, - color = palette.surface, - shape = RoundedCornerShape(10.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) - ) { + val (iconName, iconColor) = when { + 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 + } + val statusIcon = when (iconName) { + "face.dashed" -> Icons.Outlined.Explore + else -> Icons.Outlined.LocationOn + } + + val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = if (showHashSuffix) suffixRaw else "" + val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) + val baseColor = if (isMe) palette.accentOrange else assignedColor + Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(horizontal = 14.dp), + .clickable(onClick = onTap) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), verticalAlignment = Alignment.CenterVertically ) { - // Icon logic matching iOS exactly - if (hasUnreadDM) { - // Unread DM indicator (orange envelope) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(18.dp), - tint = palette.accentOrange - ) - } else { - // Face icon with teleportation state - val (iconName, iconColor) = when { - 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 + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center + ) { + if (hasUnreadDM) { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(22.dp), + tint = palette.accentOrange + ) + } else { + Icon( + imageVector = statusIcon, + contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", + modifier = Modifier.size(22.dp), + tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) + ) } - - // Teleported users get the pin (they chose this location); locals get the person. - val icon = when (iconName) { - "face.dashed" -> Icons.Outlined.Explore - else -> Icons.Outlined.LocationOn - } - - Icon( - imageVector = icon, - contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", - modifier = Modifier.size(18.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) - ) } - - Spacer(modifier = Modifier.width(8.dp)) - - // Display name with suffix handling - val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(person.displayName) - val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - - // Get consistent peer color (matches iOS color assignment exactly) - val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) - val baseColor = if (isMe) palette.accentOrange else assignedColor - + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - // Base name with peer-specific color Text( text = baseName, fontFamily = FontFamily.Monospace, @@ -245,7 +269,6 @@ private fun GeohashPersonItem( overflow = TextOverflow.Ellipsis ) - // Suffix (collision-resistant #abcd) in lighter shade if (suffix.isNotEmpty()) { Text( text = suffix, @@ -256,7 +279,6 @@ private fun GeohashPersonItem( ) } - // "You" indicator for current user if (isMe) { Text( text = stringResource(R.string.you_suffix), @@ -267,7 +289,4 @@ private fun GeohashPersonItem( } } } - } } - - diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 43038419..28ecd8a0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -60,13 +60,13 @@ import kotlinx.coroutines.launch * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. * Selection dots and row icons sit in this column so every option lines up with About settings. */ -private val ChannelLeadingSlot = 22.dp -private val ChannelLeadingGutter = 16.dp -private val ChannelRowHorizontal = 16.dp -private val ChannelRowVertical = 13.dp -private val ChannelDividerInset = ChannelRowHorizontal + ChannelLeadingSlot + ChannelLeadingGutter +private val ChannelLeadingSlot = SheetRowLeadingSlot +private val ChannelLeadingGutter = SheetRowLeadingGutter +private val ChannelRowHorizontal = SheetRowHorizontal +private val ChannelRowVertical = SheetRowVertical +private val ChannelDividerInset = SheetRowDividerInset /** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ -private val ChannelSelectedDot = 12.dp +private val ChannelSelectedDot = SheetRowSelectedDot /** * Location Channels sheet: grouped card rows matching About → Settings. @@ -187,7 +187,7 @@ fun LocationChannelsSheet( // Mesh section: icon + title header, offline subtitle, then selection card item(key = "mesh_card") { Column { - ChannelSectionHeader( + SheetIconSectionHeader( icon = Icons.Filled.Hub, title = stringResource(R.string.mesh_title), subtitle = stringResource(R.string.mesh_section_subtitle), @@ -223,7 +223,7 @@ fun LocationChannelsSheet( // Location channels: globe + title, geohash subtitle, nearby levels + teleport item(key = "channels_card") { Column { - ChannelSectionHeader( + SheetIconSectionHeader( icon = Icons.Outlined.Public, title = stringResource(R.string.location_channels_heading), subtitle = stringResource(R.string.location_channels_desc), @@ -241,7 +241,7 @@ fun LocationChannelsSheet( if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { nearbyChannels.forEachIndexed { index, channel -> - if (index > 0) ChannelCardDivider() + if (index > 0) SheetCardDivider() val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } @@ -269,10 +269,10 @@ fun LocationChannelsSheet( } ) } - ChannelCardDivider() + SheetCardDivider() } else if (showNearbyLoading) { ChannelLoadingRow() - ChannelCardDivider() + SheetCardDivider() } } @@ -352,7 +352,7 @@ fun LocationChannelsSheet( ) { Column { bookmarks.forEachIndexed { index, gh -> - if (index > 0) ChannelCardDivider() + if (index > 0) SheetCardDivider() val level = levelForLength(gh.length) val channel = GeohashChannel(level = level, geohash = gh) val coverage = coverageString(gh.length) @@ -498,63 +498,6 @@ fun LocationChannelsSheet( } } -@Composable -private fun ChannelCardDivider() { - val palette = LocalBitchatPalette.current - HorizontalDivider( - modifier = Modifier.padding(start = ChannelDividerInset), - thickness = 1.dp, - color = palette.outlineVariant - ) -} - -/** - * Section title row: icon + name on one line, optional short subtitle beneath. - */ -@Composable -private fun ChannelSectionHeader( - icon: ImageVector, - title: String, - subtitle: String, - modifier: Modifier = Modifier -) { - val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - Text( - text = title, - fontSize = 17.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - color = colorScheme.primary - ) - } - Text( - text = subtitle, - fontSize = 12.sp, - lineHeight = 17.sp, - fontFamily = FontFamily.Monospace, - color = palette.textSecondary - ) - } -} - /** * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing. * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint). diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 880c0cc6..026292fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -19,6 +19,7 @@ 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.material3.* import androidx.compose.runtime.* @@ -109,89 +110,83 @@ fun MeshPeerListSheet( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + contentPadding = PaddingValues(top = 72.dp, bottom = 32.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.Person) - AnimatedCountLabel( - count = peopleCount, - text = stringResource(R.string.people_count_title, peopleCount), - fontSize = 20.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.primary - ) - } + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> connectedPeers.count { it != viewModel.myPeerID } } // Channels section if (joinedChannels.isNotEmpty()) { - item(key = "channels_header") { - SheetSectionLabel(text = stringResource(R.string.channels)) - } - - items( - items = joinedChannels.toList(), - key = { "channel_$it" } - ) { channel -> - val isSelected = channel == currentChannel - val unreadCount = unreadChannelMessages[channel] ?: 0 - - ChannelRow( - channel = channel, - isSelected = isSelected, - unreadCount = unreadCount, - colorScheme = colorScheme, - onChannelClick = { - // Check if this is a DM channel (starts with @) - if (channel.startsWith("@")) { - // Extract peer name and find the peer ID - val peerName = channel.removePrefix("@") - val peerID = - peerNicknames.entries.firstOrNull { it.value == peerName }?.key - if (peerID != null) { - viewModel.showPrivateChatSheet(peerID) - onDismiss() + item(key = "channels_section") { + Column { + SheetIconSectionHeader( + icon = Icons.Filled.Tag, + title = stringResource(R.string.channels), + modifier = Modifier.padding(top = 8.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = LocalBitchatPalette.current.surface, + shape = AboutCardShape + ) { + Column { + joinedChannels.toList().forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 + ChannelRow( + channel = channel, + isSelected = isSelected, + unreadCount = unreadCount, + colorScheme = colorScheme, + onChannelClick = { + if (channel.startsWith("@")) { + val peerName = channel.removePrefix("@") + val peerID = + peerNicknames.entries.firstOrNull { it.value == peerName }?.key + if (peerID != null) { + viewModel.showPrivateChatSheet(peerID) + onDismiss() + } + } else { + viewModel.switchToChannel(channel) + onDismiss() + } + }, + onLeaveChannel = { + viewModel.leaveChannel(channel) + }, + ) } - } else { - // Regular channel switch - viewModel.switchToChannel(channel) - onDismiss() } - }, - onLeaveChannel = { - viewModel.leaveChannel(channel) - }, - ) + } + } } } - // People section - switch between mesh and geohash lists (iOS-compatible) + // People / geohash participants item(key = "people_section") { when (selectedLocationChannel) { is ChannelID.Location -> { - // Show geohash people list when in location channel GeohashPeopleList( viewModel = viewModel, - onTapPerson = onDismiss + onTapPerson = onDismiss, + modifier = Modifier.padding( + top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + ) ) } else -> { - // Show mesh peer list when in mesh channel (default) PeopleSection( - modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp), + modifier = Modifier.padding( + top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp + ), connectedPeers = connectedPeers, peerNicknames = peerNicknames, peerRSSI = peerRSSI, @@ -199,6 +194,7 @@ fun MeshPeerListSheet( colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, + peopleCount = peopleCount, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -239,8 +235,8 @@ fun MeshPeerListSheet( } } -/** Icon size for rows inside the sheets: one step down from the top bar. */ -private val PeerRowIconSize = 18.dp +/** Icon size for trailing actions on peer rows (matches settings glyph scale). */ +private val PeerRowIconSize = 22.dp @Composable private fun ChannelRow( @@ -253,54 +249,50 @@ private fun ChannelRow( ) { val palette = LocalBitchatPalette.current - Surface( - onClick = onChannelClick, - color = if (isSelected) palette.surfaceVariant else palette.surface, - shape = RoundedCornerShape(10.dp), + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .clickable(onClick = onChannelClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(start = 14.dp, end = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Unread badge - if (unreadCount > 0) { - UnreadBadge( - count = unreadCount, - colorScheme = colorScheme - ) - } - + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(palette.accentGreen, CircleShape) + ) + } else if (unreadCount > 0) { + UnreadBadge(count = unreadCount, colorScheme = colorScheme) + } else { Text( - text = channel, + text = "#", fontFamily = FontFamily.Monospace, fontSize = 14.sp, - color = if (isSelected) colorScheme.primary else palette.textPrimary, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Leave channel button - CloseButton( - onClick = onLeaveChannel, + fontWeight = FontWeight.Medium, + color = palette.textTertiary ) } } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Text( + text = channel, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else palette.textPrimary, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + CloseButton(onClick = onLeaveChannel) } } @@ -316,6 +308,7 @@ fun PeopleSection( colorScheme: ColorScheme, selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), + peopleCount: Int = 0, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -327,19 +320,31 @@ fun PeopleSection( val palette = LocalBitchatPalette.current Column(modifier = modifier) { - SheetSectionLabel(text = stringResource(R.string.people)) + SheetIconSectionHeader( + icon = Icons.Filled.Person, + title = stringResource(R.string.people_count_title, peopleCount) + ) - if (connectedPeers.isEmpty()) { - Text( - text = stringResource(id = R.string.no_one_connected), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = palette.textTertiary, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 12.dp) - ) - } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical) + ) + } // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() @@ -443,7 +448,11 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } + var peerIndex = 0 + sortedPeers.forEach { peerID -> + if (peerIndex > 0) SheetCardDivider() + peerIndex++ val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false @@ -492,6 +501,9 @@ fun PeopleSection( val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) if (isFavoriteMappedToConnected(fav)) return@forEach + if (peerIndex > 0) SheetCardDivider() + peerIndex++ + val nostrConvKey: String? = try { FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) ?.let { ContactIdentityResolver.nostrAliasForPubkey(it) } @@ -535,6 +547,8 @@ fun PeopleSection( showHashSuffix = showHash ) } + } + } } } @@ -568,131 +582,112 @@ private fun PeerItem( val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark) val baseColor = if (isMe) palette.accentOrange else assignedColor - Surface( - onClick = onItemClick, - color = if (isSelected) palette.surfaceVariant else palette.surface, - shape = RoundedCornerShape(10.dp), + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = SheetHorizontalPadding, vertical = 3.dp) + .clickable(onClick = onItemClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(start = 14.dp, end = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(SheetRowLeadingSlot), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Connection/status indicator - if (hasUnreadDM) { - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(R.string.cd_unread_message), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentOrange - ) - } else if (showNostrGlobe) { - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_reachable_via_nostr), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentPurple - ) - } else if (!isDirect && isFavorite) { - // Offline favourite: routed glyph, dimmed to read as unavailable. - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textTertiary - ) - } else { - Icon( - imageVector = when { - isWifiAware -> Icons.Filled.Wifi - isDirect -> Icons.Outlined.Bluetooth - else -> Icons.Filled.Route - }, - contentDescription = when { - isWifiAware -> "Direct Wi-Fi Aware" - isDirect -> "Direct Bluetooth" - else -> "Routed" - }, - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textSecondary - ) - } - - // Display name with iOS-style color and hashtag suffix support - Row(verticalAlignment = Alignment.CenterVertically) { - // Base name with peer-specific color - Text( - text = baseName, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - if (suffix.isNotEmpty()) { - Text( - text = suffix, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, - color = baseColor.copy(alpha = SUFFIX_ALPHA) - ) - } - - if (isWifiAware && hasUnreadDM) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(PeerRowIconSize), - tint = palette.textSecondary - ) - } - } - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(palette.accentGreen, CircleShape) + ) + } else if (hasUnreadDM) { Icon( - imageVector = Icons.Filled.Verified, - contentDescription = stringResource(R.string.verify_title), + imageVector = Icons.Filled.Email, + contentDescription = stringResource(R.string.cd_unread_message), modifier = Modifier.size(PeerRowIconSize), - tint = palette.accentGreen + tint = palette.accentOrange + ) + } else if (showNostrGlobe) { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_reachable_via_nostr), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentPurple + ) + } else if (!isDirect && isFavorite) { + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(R.string.cd_offline_favorite), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textTertiary + ) + } else { + Icon( + imageVector = when { + isWifiAware -> Icons.Filled.Wifi + isDirect -> Icons.Outlined.Bluetooth + else -> Icons.Filled.Route + }, + contentDescription = when { + isWifiAware -> "Direct Wi-Fi Aware" + isDirect -> "Direct Bluetooth" + else -> "Routed" + }, + modifier = Modifier.size(PeerRowIconSize), + tint = palette.textSecondary + ) + } + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = baseName, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (suffix.isNotEmpty()) { + Text( + text = suffix, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Favorite star with proper filled/outlined states - IconButton( - onClick = onToggleFavorite, - modifier = Modifier.size(44.dp) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(PeerRowIconSize), - tint = if (isFavorite) palette.accentOrange else palette.textTertiary - ) - } + if (isVerified) { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(16.dp), + tint = palette.accentGreen + ) } } + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onToggleFavorite), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(PeerRowIconSize), + tint = if (isFavorite) palette.accentOrange else palette.textTertiary + ) + } } } From 32d5e6f1dc133d7889af979a8227162aec253575 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:51:25 +0200 Subject: [PATCH 12/37] cleanup --- .../main/java/com/bitchat/android/ui/GeohashPeopleList.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index b29c12bc..58f2f44a 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -2,20 +2,15 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn -import androidx.compose.material.icons.outlined.Public import android.util.Log -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp From c0b1af3d0f79e806e45cb6c458afaafaf2c16779 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:59:51 +0200 Subject: [PATCH 13/37] button --- .../java/com/bitchat/android/ui/ChatHeader.kt | 109 +++++++++++++++--- .../bitchat/android/ui/LocationNotesButton.kt | 24 ++-- 2 files changed, 100 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 9d831592..d2e6a9b6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -7,7 +7,12 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* @@ -24,6 +29,9 @@ 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.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.res.stringResource @@ -96,30 +104,91 @@ private fun HeaderIconButton( } /** - * Smooth tint for header glyphs that should reflect Tor connection state. + * Tor health for location-channel header glyphs. * - * When Tor is off or fully bootstrapped, [normal] is used. While connecting the tint goes - * orange; if Tor is enabled but not running it goes red. Cross-fades so Bootstrap percent - * flips do not flash the whole header. + * Status colours are heavily muted (blended into [normal]) so they read as a soft signal + * rather than an alarm. Connecting / not-yet-running also drives a slow glow pulse. */ +internal data class TorConnectionVisual( + val tint: Color, + /** True while Tor is enabled but not fully bootstrapped — drives a pulse. */ + val isProgress: Boolean, +) + @Composable -internal fun torConnectionTint(normal: Color): Color { +internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { val palette = LocalBitchatPalette.current val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState() + // ~28% of the loud accent mixed into the base tint keeps the hue without intensity. + val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f) + val mutedFailed = lerp(normal, palette.accentRed, 0.30f) + val target = when { - torStatus.mode == TorMode.OFF -> normal - torStatus.running && torStatus.bootstrapPercent >= 100 -> normal - torStatus.running -> palette.accentOrange - else -> palette.accentRed + torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false) + torStatus.running && torStatus.bootstrapPercent >= 100 -> + TorConnectionVisual(normal, isProgress = false) + torStatus.running -> TorConnectionVisual(mutedConnecting, isProgress = true) + else -> TorConnectionVisual(mutedFailed, isProgress = true) } - val animated by animateColorAsState( - targetValue = target, + val animatedTint by animateColorAsState( + targetValue = target.tint, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "torConnectionTint" ) - return animated + return TorConnectionVisual(tint = animatedTint, isProgress = target.isProgress) +} + +/** + * Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout + * does not shift; only opacity / a faint halo breathe. + */ +@Composable +internal fun TorAwareHeaderIcon( + imageVector: ImageVector, + tint: Color, + isProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + val pulse = if (isProgress) { + val transition = rememberInfiniteTransition(label = "torGlow") + transition.animateFloat( + initialValue = 0.42f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "torGlowPulse" + ).value + } else { + 1f + } + + Box(contentAlignment = Alignment.Center, modifier = modifier) { + if (isProgress) { + // Soft halo behind the glyph — opacity tracks the pulse, never solid. + Box( + modifier = Modifier + .size(HeaderIconSize + 10.dp) + .graphicsLayer { alpha = pulse * 0.28f } + .background(tint.copy(alpha = 1f), CircleShape) + ) + } + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + // Pulse icon opacity gently when in progress; settle to full when idle. + alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + }, + tint = tint + ) + } } @Composable @@ -530,8 +599,12 @@ private fun LocationChannelsButton( else -> stringResource(R.string.mesh_label) } val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue - // Icon alone carries Tor health so the geohash / "mesh" label stays channel-coloured. - val iconColor = torConnectionTint(normal = channelColor) + // Tor status only tints the globe (location channels). Mesh Bluetooth stays blue. + val torVisual = if (isLocation) { + rememberTorConnectionVisual(normal = channelColor) + } else { + TorConnectionVisual(tint = channelColor, isProgress = false) + } val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth Row( @@ -545,11 +618,11 @@ private fun LocationChannelsButton( // padding so the gap to PeerCounter matches other cluster separations. .padding(start = 0.dp, end = 6.dp) ) { - Icon( + TorAwareHeaderIcon( imageVector = badgeIcon, - contentDescription = stringResource(R.string.cd_tor_status), - modifier = Modifier.size(HeaderIconSize), - tint = iconColor + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = stringResource(R.string.cd_tor_status) ) Text( diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 4819deef..7c29a93a 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -25,10 +24,9 @@ import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.ui.theme.LocalBitchatPalette /** - * Location Notes button component for MainHeader - * Shows in mesh mode when location permission granted AND services enabled. - * Base tint is primary when notes exist, secondary otherwise; Tor health may - * override that color (orange connecting, red failed) via [torConnectionTint]. + * Location Notes button for MainHeader. + * Mesh-only with location authorized. Tor health tints the glyph with muted colours + * and a slow glow while connecting (via [rememberTorConnectionVisual]). */ @Composable fun LocationNotesButton( @@ -40,17 +38,14 @@ fun LocationNotesButton( val palette = LocalBitchatPalette.current val context = LocalContext.current - // Get channel and permission state val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) - // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationEnabled = locationPermissionGranted && locationServicesEnabled - // Get notes count from LocationNotesManager val notesManager = remember { LocationNotesManager.getInstance() } val notes by notesManager.notes.collectAsStateWithLifecycle() val notesCount = notes.size @@ -60,9 +55,8 @@ fun LocationNotesButton( val hasNotes = notesCount > 0 val contentDescription = stringResource(R.string.cd_location_notes) val normalTint = if (hasNotes) colorScheme.primary else palette.textSecondary - val tint = torConnectionTint(normal = normalTint) - // Match other header icon buttons: 44.dp target, no Material IconButton min-size padding - // that pushed the notes glyph farther from the mesh badge than sibling gaps. + val torVisual = rememberTorConnectionVisual(normal = normalTint) + Box( modifier = modifier .size(44.dp) @@ -70,11 +64,11 @@ fun LocationNotesButton( .clickable(onClickLabel = contentDescription) { onClick() }, contentAlignment = Alignment.Center ) { - Icon( + TorAwareHeaderIcon( imageVector = Icons.Outlined.Description, - contentDescription = contentDescription, - modifier = Modifier.size(HeaderIconSize), - tint = tint + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = contentDescription ) } } From 8e849a7bbea1dd0381c7735c6ae0bd735e591bd1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:14:54 +0200 Subject: [PATCH 14/37] fixes --- .../ui/component/button/BitChatBrandButton.kt | 9 +- .../ui/component/sheet/BitchatBottomSheet.kt | 42 ++++- .../ui/component/sheet/BitchatSheetTopBar.kt | 7 +- .../java/com/bitchat/android/ui/AboutSheet.kt | 45 +++++- .../bitchat/android/ui/AnimatedRowColumn.kt | 85 +++++++++++ .../java/com/bitchat/android/ui/ChatHeader.kt | 68 ++++++--- .../bitchat/android/ui/GeohashPeopleList.kt | 143 +++++++++++++----- .../android/ui/LocationChannelsSheet.kt | 74 ++++++++- .../bitchat/android/ui/LocationNotesButton.kt | 2 + .../bitchat/android/ui/MeshPeerListSheet.kt | 36 +++-- .../com/bitchat/android/ui/PressFeedback.kt | 64 ++++++++ .../android/ui/SecurityVerificationSheet.kt | 4 +- .../bitchat/android/ui/VerificationSheet.kt | 4 +- app/src/main/res/values/strings.xml | 1 + .../android/ui/GeohashAnonOrderingTest.kt | 113 ++++++++++++++ 15 files changed, 606 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/PressFeedback.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index c1361364..f4afd5b6 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -12,11 +12,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.rememberPressScale import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -39,6 +42,9 @@ fun BitChatBrandButton( val currentOnClick by rememberUpdatedState(onClick) val currentOnTripleClick by rememberUpdatedState(onTripleClick) + val interactionSource = remember { MutableInteractionSource() } + val pressScale = rememberPressScale(interactionSource) + IconButton( onClick = { tapCount += 1 @@ -59,7 +65,8 @@ fun BitChatBrandButton( } } }, - modifier = modifier, + modifier = modifier.scale(pressScale), + interactionSource = interactionSource, ) { Icon( imageVector = BitChatIcon, diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt index 948fd6d9..2b14c09e 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt @@ -9,8 +9,26 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first. + * + * `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap + * on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from + * a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the + * composable out of the tree and makes the sheet vanish instantly. + * + * Anything inside a sheet that wants to close it should prefer this over calling its own + * `onDismiss` directly. + */ +val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null } @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -20,6 +38,21 @@ fun BitchatBottomSheet( onDismissRequest: () -> Unit, content: @Composable (ColumnScope.() -> Unit), ) { + val scope = rememberCoroutineScope() + + // Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()` + // throws if the sheet is already on its way out (two rapid taps on a close button), which is + // benign — the dismissal still has to go through. + val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) { + { + scope.launch { + runCatching { sheetState.hide() } + onDismissRequest() + } + Unit + } + } + ModalBottomSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismissRequest, @@ -27,6 +60,9 @@ fun BitchatBottomSheet( dragHandle = null, shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), containerColor = MaterialTheme.colorScheme.background, - content = content, - ) -} \ No newline at end of file + ) { + CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) { + content() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt index 036cf01b..333e3ae6 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider import com.bitchat.android.core.ui.component.button.CloseButton @OptIn(ExperimentalMaterial3Api::class) @@ -30,8 +31,9 @@ fun BitchatSheetTopBar( navigationIcon = { navigationIcon?.invoke() }, actions = { actions() + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onClose, + onClick = { dismiss?.invoke() ?: onClose() }, modifier = Modifier.padding(horizontal = 16.dp) ) }, @@ -59,8 +61,9 @@ fun BitchatSheetCenterTopBar( navigationIcon = { navigationIcon?.invoke() }, actions = { actions() + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onClose, + onClick = { dismiss?.invoke() ?: onClose() }, modifier = Modifier.padding(horizontal = 16.dp) ) }, diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 6be3cff1..821ccabd 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -5,6 +5,8 @@ import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Speed import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -30,6 +32,7 @@ import com.bitchat.android.nostr.PoWPreferenceManager import androidx.compose.ui.res.stringResource import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager @@ -101,17 +104,43 @@ private fun SettingsToggleRow( ) { val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + + // Colours cross-fade so a row becoming available (Tor finishing bootstrap) eases in rather + // than popping. + val iconTint by animateColorAsState( + targetValue = if (enabled) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowIcon" + ) + val titleColor by animateColorAsState( + targetValue = if (enabled) palette.textPrimary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowTitle" + ) + val subtitleColor by animateColorAsState( + targetValue = if (enabled) palette.textSecondary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowSubtitle" + ) Row( modifier = Modifier .fillMaxWidth() + // The whole row toggles, not just the switch: a 14.dp-tall switch is a poor target + // when there is a full-width row sitting right next to it. + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onCheckedChange(!checked) } .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = icon, contentDescription = null, - tint = if (enabled) colorScheme.primary else palette.textTertiary, + tint = iconTint, modifier = Modifier.size(22.dp) ) @@ -130,7 +159,7 @@ private fun SettingsToggleRow( fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) palette.textPrimary else palette.textTertiary + color = titleColor ) statusIndicator?.invoke() } @@ -138,7 +167,7 @@ private fun SettingsToggleRow( text = subtitle, fontFamily = FontFamily.Monospace, fontSize = 12.sp, - color = if (enabled) palette.textSecondary else palette.textTertiary, + color = subtitleColor, lineHeight = 17.sp ) } @@ -149,6 +178,7 @@ private fun SettingsToggleRow( checked = checked, onCheckedChange = { if (enabled) onCheckedChange(it) }, enabled = enabled, + interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, checkedTrackColor = palette.accentGreen, @@ -416,7 +446,8 @@ fun AboutSheet( fontWeight = FontWeight.Medium, color = palette.textPrimary ) - Text( + AnimatedCountLabel( + count = powDifficulty, text = stringResource( R.string.about_difficulty_value, powDifficulty, @@ -440,7 +471,8 @@ fun AboutSheet( ) ) - Text( + AnimatedCountLabel( + count = powDifficulty, text = when { powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) @@ -560,8 +592,9 @@ fun AboutSheet( .height(64.dp) .background(palette.background.copy(alpha = topBarAlpha)) ) { + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onDismiss, + onClick = { dismiss?.invoke() ?: onDismiss() }, modifier = modifier .align(Alignment.CenterEnd) .padding(horizontal = 16.dp), diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt new file mode 100644 index 00000000..26c8259a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope + +/** + * Motion for a row moving to a new position, or resizing in place. + * + * People lists reorder themselves constantly and without user input — someone sends a DM and jumps + * to the top, a peer drops off the mesh, a favourite comes online. Rows teleporting between + * positions makes the list feel unreliable and costs the reader their place in it. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +private val RowBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} + +/** Entry fade for a row that was not previously in the list. */ +private val RowEnterSpec: FiniteAnimationSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 900f) + +/** + * A vertical list whose rows animate when they are added, removed, or reordered. + * + * Deliberately not a `LazyColumn`: both people lists live *inside* an outer `LazyColumn` item, where + * nesting another lazy list is not possible. [LookaheadScope] plus [Modifier.animateBounds] gives + * the same reorder-and-resize animation that `LazyItemScope.animateItem` provides, without the list + * needing to be lazy. These lists are bounded (and the geohash one is explicitly capped), so + * nothing is lost by composing every row. + * + * Rows are keyed so identity survives reordering. Without stable keys a row that moved would look + * like a different row appearing, and would fade instead of sliding. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun AnimatedRowColumn( + items: List, + key: (T) -> Any, + modifier: Modifier = Modifier, + row: @Composable (index: Int, item: T) -> Unit +) { + LookaheadScope { + Column(modifier = modifier) { + items.forEachIndexed { index, item -> + key(key(item)) { + // Fades the row in on the composition it first appears, then never again — + // reordering an existing row must slide, not blink. + val enter = remember { Animatable(0f) } + LaunchedEffect(Unit) { enter.animateTo(1f, RowEnterSpec) } + + Box( + modifier = Modifier + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = RowBoundsTransform + ) + .graphicsLayer { alpha = enter.value } + ) { + row(index, item) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index d2e6a9b6..fad7b82e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -27,20 +27,21 @@ 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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.res.stringResource -import com.bitchat.android.R +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.BitChatBrandButton import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode @@ -96,7 +97,7 @@ private fun HeaderIconButton( modifier = modifier .size(HeaderTapTarget) .clip(CircleShape) - .clickable(onClickLabel = contentDescription) { onClick() }, + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), contentAlignment = Alignment.Center ) { content() @@ -167,14 +168,27 @@ internal fun TorAwareHeaderIcon( 1f } - Box(contentAlignment = Alignment.Center, modifier = modifier) { + // Fixed layout footprint = icon size. Glow is drawn larger via requiredSize so it never + // pushes neighbouring text when the pulse starts/stops. + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { if (isProgress) { - // Soft halo behind the glyph — opacity tracks the pulse, never solid. + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } Box( modifier = Modifier - .size(HeaderIconSize + 10.dp) - .graphicsLayer { alpha = pulse * 0.28f } - .background(tint.copy(alpha = 1f), CircleShape) + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f } + .background(glowBrush) ) } Icon( @@ -183,7 +197,6 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - // Pulse icon opacity gently when in progress; settle to full when idle. alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f }, tint = tint @@ -323,7 +336,7 @@ fun PeerCounter( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .clip(HeaderClusterShape) - .clickable { onClick() } + .pressScaleClickable(onClick = onClick) .height(HeaderTapTarget) .padding(horizontal = 6.dp) ) { @@ -438,7 +451,7 @@ private fun ChannelHeader( modifier = Modifier .align(Alignment.Center) .clip(HeaderClusterShape) - .clickable { onSidebarClick() } + .pressScaleClickable(onClick = onSidebarClick) .padding(horizontal = 8.dp, vertical = 4.dp) ) @@ -498,20 +511,27 @@ private fun MainHeader( modifier = Modifier.size(HeaderTapTarget), ) - Text( - text = "/", - style = MaterialTheme.typography.bodyMedium, - fontSize = HeaderTextSize, - // 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) - ) + // Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than + // spacing between the mark and the path label. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.offset(x = (-6).dp) + ) { + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + // 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(end = 2.dp) + ) - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange + ) + } } // MARK: - Status cluster. diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 58f2f44a..75fe577c 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -5,12 +5,18 @@ import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -19,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -92,14 +99,15 @@ fun GeohashPeopleList( } } + // Self first, then anyone who chose a nickname, then the anons — all by recency + // within their group. A busy geohash is mostly anonymous drive-by participants, and + // letting them sort by recency alone buried the handful of people worth recognising. val orderedPeople = remember(geohashPeople, myHex) { - geohashPeople.sortedWith { a, b -> - when { - myHex != null && a.id == myHex && b.id != myHex -> -1 - myHex != null && b.id == myHex && a.id != myHex -> 1 - else -> b.lastSeen.compareTo(a.lastSeen) - } - } + geohashPeople.sortedWith( + compareByDescending { myHex != null && it.id == myHex } + .thenBy { it.isAnonymous() } + .thenByDescending { it.lastSeen } + ) } val baseNameCounts = remember(geohashPeople) { @@ -145,21 +153,7 @@ fun GeohashPeopleList( icon = Icons.Outlined.LocationOn, title = stringResource(R.string.section_on_location) ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - localPeople.forEachIndexed { index, person -> - if (index > 0) SheetCardDivider() - personRow(person) - } - } - } + PeopleCard(people = localPeople, row = { personRow(it) }) } if (teleportedPeople.isNotEmpty()) { @@ -168,22 +162,103 @@ fun GeohashPeopleList( title = stringResource(R.string.section_teleported_in), modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp) ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - teleportedPeople.forEachIndexed { index, person -> - if (index > 0) SheetCardDivider() - personRow(person) + PeopleCard(people = teleportedPeople, row = { personRow(it) }) + } + } + } +} + +/** Anonymous participants beyond this many are hidden behind the "n more" affordance. */ +internal const val MaxVisibleAnons = 5 + +/** + * Whether this participant never set a nickname. + * + * The app labels them `anon` or `anon1234` before the `#abcd` disambiguator, so the base name is + * what identifies them. + */ +internal fun GeoPerson.isAnonymous(): Boolean { + val base = splitSuffix(displayName).first + return base == "anon" || (base.startsWith("anon") && base.drop(4).all { it.isDigit() }) +} + +/** + * One grouped card of people, with a cap on how many anonymous participants are shown. + * + * A popular geohash can hold dozens of anons, which pushed everyone worth recognising off screen and + * turned the sheet into a wall of near-identical rows. Named participants are always listed in full; + * anons are trimmed to [MaxVisibleAnons], and the overflow is collapsed behind a count. The last + * visible anon fades out under a gradient so the truncation is legible as truncation rather than + * looking like the list simply ended. + */ +@Composable +private fun PeopleCard( + people: List, + row: @Composable (GeoPerson) -> Unit +) { + val palette = LocalBitchatPalette.current + + val named = people.filterNot { it.isAnonymous() } + val anons = people.filter { it.isAnonymous() } + val visibleAnons = anons.take(MaxVisibleAnons) + val hiddenAnonCount = anons.size - visibleAnons.size + val visible = named + visibleAnons + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + if (hiddenAnonCount > 0 && index == visible.lastIndex) { + // Fade only the final row, so the gradient reads as "the list continues" + // rather than dimming content that is still meant to be read. + Box { + row(person) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + listOf( + palette.surface.copy(alpha = 0f), + palette.surface.copy(alpha = 0.85f) + ) + ) + ) + ) } + } else { + row(person) } } } + + AnimatedVisibility( + visible = hiddenAnonCount > 0, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + ) { + Text( + text = stringResource(R.string.people_n_more, hiddenAnonCount), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = palette.textTertiary, + modifier = Modifier + .fillMaxWidth() + .padding( + start = SheetRowHorizontal, + end = SheetRowHorizontal, + bottom = SheetRowVertical + ) + ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 28ecd8a0..84ba3709 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,5 +1,14 @@ package com.bitchat.android.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Hub @@ -43,6 +52,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.GeohashBookmarksStore @@ -54,6 +64,7 @@ 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 +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -68,6 +79,14 @@ private val ChannelDividerInset = SheetRowDividerInset /** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ private val ChannelSelectedDot = SheetRowSelectedDot +/** + * Pause between applying a channel selection and dismissing the sheet. + * + * Just enough for the active dot to land on the chosen row, so the tap is acknowledged rather than + * answered by the sheet simply disappearing. + */ +private const val SelectionConfirmDelayMs = 180L + /** * Location Channels sheet: grouped card rows matching About → Settings. */ @@ -141,6 +160,18 @@ fun LocationChannelsSheet( onDismissRequest = onDismiss, sheetState = sheetState, ) { + // Selection is applied immediately so the active dot snaps to the new row, then the + // sheet slides away after a beat. Long enough to register the change, short enough + // that it never feels like waiting. + val animatedDismiss = LocalSheetDismiss.current + val confirmSelectionThenDismiss: () -> Unit = { + coroutineScope.launch { + delay(SelectionConfirmDelayMs) + animatedDismiss?.invoke() ?: onDismiss() + } + Unit + } + Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, @@ -324,9 +355,22 @@ fun LocationChannelsSheet( } } - if (customError != null) { + AnimatedVisibility( + visible = customError != null, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + // Held across the exit animation: by the time it plays, the error + // itself has already been cleared. + val shownError = remember(customError) { customError ?: "" } Text( - text = customError!!, + text = shownError, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = palette.accentRed, @@ -658,7 +702,24 @@ private fun CustomGeohashRow( val palette = LocalBitchatPalette.current val normalized = customGeohash.trim().lowercase().replace("#", "") val isValid = validateGeohash(normalized) - val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary + // Typing the last character of a valid geohash arms the button; cross-fading both the label + // and its container makes that the moment the row confirms the input is usable. + val teleportColor by animateColorAsState( + targetValue = if (isValid) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportLabel" + ) + val teleportContainer by animateColorAsState( + targetValue = if (isValid) { + colorScheme.primary.copy(alpha = 0.16f) + } else { + palette.surfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportContainer" + ) + val teleportInteraction = remember { MutableInteractionSource() } + val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f) Row( modifier = Modifier @@ -718,7 +779,8 @@ private fun CustomGeohashRow( Box( modifier = Modifier .size(36.dp) - .clickable(onClick = onOpenMap), + .clip(CircleShape) + .pressScaleClickable(onClick = onOpenMap), contentAlignment = Alignment.Center ) { Icon( @@ -733,7 +795,9 @@ private fun CustomGeohashRow( onClick = onTeleport, enabled = isValid, shape = RoundedCornerShape(8.dp), - color = palette.surfaceVariant + color = teleportContainer, + interactionSource = teleportInteraction, + modifier = Modifier.scale(teleportScale) ) { Text( text = stringResource(R.string.teleport).uppercase(), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 7c29a93a..ab14a999 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -12,7 +12,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 026292fc..cfda3952 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -448,11 +448,21 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } - var peerIndex = 0 + // Every row this card will show, in final order, so the animated list can key on identity + // and animate reordering. Offline favourites are appended after the connected peers. + // Collected once for the whole card rather than once per row. + val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() - sortedPeers.forEach { peerID -> - if (peerIndex > 0) SheetCardDivider() - peerIndex++ + val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) } + val rowKeys: List = sortedPeers + + offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) } + + AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey -> + Column { + if (rowIndex > 0) SheetCardDivider() + val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey } + if (connectedPeerForRow != null) { + val peerID = connectedPeerForRow val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false @@ -472,7 +482,6 @@ fun PeopleSection( val (bName, _) = splitSuffix(displayName) val showHash = (baseNameCounts[bName] ?: 0) > 1 - val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } PeerItem( peerID = peerID, @@ -494,15 +503,12 @@ fun PeopleSection( showNostrGlobe = false, showHashSuffix = showHash ) - } - - // Append offline favorites we actively favorite (and not currently connected) - offlineFavorites.forEach { fav -> - val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) - if (isFavoriteMappedToConnected(fav)) return@forEach - - if (peerIndex > 0) SheetCardDivider() - peerIndex++ + } else { + // Offline favourite: still worth showing, reachable over Nostr. + val fav = offlineFavoriteRows.first { + ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) == rowKey + } + val favPeerID = rowKey val nostrConvKey: String? = try { FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) @@ -546,6 +552,8 @@ fun PeopleSection( showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), showHashSuffix = showHash ) + } + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt new file mode 100644 index 00000000..01c9c6ca --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale + +/** + * Press feedback for the app's icon buttons. + * + * Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost + * nothing to show. A brief scale dip is legible on any background and reads as physical. Springs + * rather than tweens, so releasing overshoots very slightly instead of stopping dead. + */ +@Composable +fun rememberPressScale( + interactionSource: InteractionSource, + pressedScale: Float = 0.86f +): Float { + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) pressedScale else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "pressScale" + ) + return scale +} + +/** + * Convenience wrapper for the common case: a clickable that scales while held. + * + * Returns the modifier chain to apply, having disabled the default indication — the scale *is* the + * indication, and a ripple underneath it just muddies the edges of these small glyphs. + */ +@Composable +fun Modifier.pressScaleClickable( + onClick: () -> Unit, + enabled: Boolean = true, + onClickLabel: String? = null, + pressedScale: Float = 0.86f +): Modifier { + val interactionSource = remember { MutableInteractionSource() } + val scale = rememberPressScale(interactionSource, pressedScale) + return this + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClickLabel = onClickLabel, + onClick = onClick + ) + .scale(scale) +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 5549f43f..5d3af681 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet private data class SecurityStatusInfo( @@ -161,7 +162,8 @@ private fun SecurityVerificationHeader( color = accent ) Spacer(modifier = Modifier.weight(1f)) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index a81938d5..76aed7d8 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -72,6 +72,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.services.VerificationService import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -235,7 +236,8 @@ private fun VerificationHeader( fontFamily = FontFamily.Monospace, color = accent ) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0987249..05d99721 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -256,6 +256,7 @@ People (%1$d) On location Teleported in + %1$d more\u2026 Grant location permission Location permission denied. Enable in settings to use location channels. diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt new file mode 100644 index 00000000..8a00d1c8 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt @@ -0,0 +1,113 @@ +package com.bitchat.android.ui + +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A busy geohash is mostly anonymous drive-by participants. If they sort by recency alongside + * everyone else they bury the handful of people worth recognising, so they are pushed to the end of + * the list and capped. + */ +class GeohashAnonOrderingTest { + + private fun person(name: String, secondsAgo: Long = 0) = GeoPerson( + id = "id-$name", + displayName = name, + lastSeen = Date(1_000_000L - secondsAgo * 1000L) + ) + + @Test + fun `bare anon is anonymous`() { + assertTrue(person("anon").isAnonymous()) + assertTrue(person("anon#04af").isAnonymous()) + } + + @Test + fun `numbered anon is anonymous`() { + assertTrue(person("anon7674").isAnonymous()) + assertTrue(person("anon7674#df5b").isAnonymous()) + } + + @Test + fun `a chosen nickname is not anonymous`() { + assertFalse(person("alice").isAnonymous()) + assertFalse(person("alice#1234").isAnonymous()) + } + + @Test + fun `a nickname that merely starts with anon is not anonymous`() { + // "anonymous" and "anonracer" are deliberate names, not the generated placeholder. + assertFalse(person("anonymous").isAnonymous()) + assertFalse(person("anonracer#04af").isAnonymous()) + } + + @Test + fun `named participants sort ahead of anons regardless of recency`() { + val me = person("me") + val people = listOf( + person("anon1", secondsAgo = 0), // most recent of all + person("zoe", secondsAgo = 500), // stalest named user + person("anon2", secondsAgo = 10), + person("alice", secondsAgo = 200) + ) + + val ordered = (people + me).sortedWith( + compareByDescending { it.id == me.id } + .thenBy { it.isAnonymous() } + .thenByDescending { it.lastSeen } + ) + + assertEquals( + listOf("me", "alice", "zoe", "anon1", "anon2"), + ordered.map { it.displayName } + ) + } + + @Test + fun `recency still orders within each group`() { + val people = listOf( + person("bob", secondsAgo = 100), + person("alice", secondsAgo = 10), + person("anon9", secondsAgo = 300), + person("anon1", secondsAgo = 5) + ) + + val ordered = people.sortedWith( + compareBy { it.isAnonymous() }.thenByDescending { it.lastSeen } + ) + + assertEquals( + listOf("alice", "bob", "anon1", "anon9"), + ordered.map { it.displayName } + ) + } + + @Test + fun `named participants are never trimmed`() { + val named = (1..20).map { person("user$it") } + assertEquals(20, named.filterNot { it.isAnonymous() }.size) + } + + @Test + fun `anons beyond the cap are counted as hidden`() { + val people = (1..12).map { person("anon$it") } + person("alice") + + val anons = people.filter { it.isAnonymous() } + val visible = anons.take(MaxVisibleAnons) + + assertEquals(MaxVisibleAnons, visible.size) + assertEquals(12 - MaxVisibleAnons, anons.size - visible.size) + } + + @Test + fun `a short anon list is shown in full with nothing hidden`() { + val people = listOf(person("alice"), person("anon1"), person("anon2")) + + val anons = people.filter { it.isAnonymous() } + assertEquals(2, anons.take(MaxVisibleAnons).size) + assertEquals(0, anons.size - anons.take(MaxVisibleAnons).size) + } +} From 5428bc9a03c7c53f41e956d3de7423118ff06b5d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:22:16 +0200 Subject: [PATCH 15/37] animations --- .../ui/component/button/BitChatBrandButton.kt | 56 ++++++++++++------- .../ui/component/sheet/BitchatBottomSheet.kt | 1 - .../java/com/bitchat/android/ui/ChatHeader.kt | 33 ++++++----- .../android/ui/LocationChannelsSheet.kt | 1 - .../bitchat/android/ui/LocationNotesButton.kt | 3 +- 5 files changed, 56 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index f4afd5b6..731a85fb 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -1,8 +1,11 @@ package com.bitchat.android.core.ui.component.button +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -12,8 +15,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp @@ -45,34 +49,44 @@ fun BitChatBrandButton( val interactionSource = remember { MutableInteractionSource() } val pressScale = rememberPressScale(interactionSource) - IconButton( - onClick = { - tapCount += 1 - resetJob?.cancel() + // A plain Box rather than an IconButton: IconButton insists on drawing a ripple, which was the + // only press background left in the header once every other control moved to scale-only + // feedback. + Box( + modifier = modifier + .clip(CircleShape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClickLabel = contentDescription + ) { + tapCount += 1 + resetJob?.cancel() - if (tapCount == 3) { - tapCount = 0 - resetJob = null - currentOnTripleClick() - } else { - resetJob = coroutineScope.launch { - delay(MultiClickThreshold) - if (tapCount == 1) { - currentOnClick() - } + if (tapCount == 3) { tapCount = 0 resetJob = null + currentOnTripleClick() + } else { + resetJob = coroutineScope.launch { + delay(MultiClickThreshold) + if (tapCount == 1) { + currentOnClick() + } + tapCount = 0 + resetJob = null + } } - } - }, - modifier = modifier.scale(pressScale), - interactionSource = interactionSource, + }, + contentAlignment = Alignment.Center ) { Icon( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(iconSize), + modifier = Modifier + .size(iconSize) + .scale(pressScale), ) } } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt index 2b14c09e..e53fed46 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt @@ -49,7 +49,6 @@ fun BitchatBottomSheet( runCatching { sheetState.hide() } onDismissRequest() } - Unit } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index fad7b82e..6e79ab91 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -452,21 +452,25 @@ private fun ChannelHeader( .align(Alignment.Center) .clip(HeaderClusterShape) .pressScaleClickable(onClick = onSidebarClick) - .padding(horizontal = 8.dp, vertical = 4.dp) + .heightIn(min = HeaderTapTarget) + .wrapContentHeight(Alignment.CenterVertically) + .padding(horizontal = 10.dp) ) // Leave button - positioned on the right - TextButton( - onClick = onLeaveChannel, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Text( - text = stringResource(R.string.chat_leave), - style = MaterialTheme.typography.labelMedium, - fontSize = 15.sp, - color = palette.accentRed - ) - } + Text( + text = stringResource(R.string.chat_leave), + style = MaterialTheme.typography.labelMedium, + fontSize = 15.sp, + color = palette.accentRed, + modifier = Modifier + .align(Alignment.CenterEnd) + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onLeaveChannel) + .heightIn(min = HeaderTapTarget) + .wrapContentHeight(Alignment.CenterVertically) + .padding(horizontal = 10.dp) + ) } } @@ -632,7 +636,10 @@ private fun LocationChannelsButton( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .clip(HeaderClusterShape) - .clickable(onClickLabel = stringResource(R.string.location_channels_title)) { onClick() } + .pressScaleClickable( + onClick = onClick, + onClickLabel = stringResource(R.string.location_channels_title) + ) .height(HeaderTapTarget) // No start padding: the notes icon is paired directly to the left; keep end // padding so the gap to PeerCounter matches other cluster separations. diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 84ba3709..5c4c230e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -169,7 +169,6 @@ fun LocationChannelsSheet( delay(SelectionConfirmDelayMs) animatedDismiss?.invoke() ?: onDismiss() } - Unit } Box(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index ab14a999..23157e5d 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -2,7 +2,6 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Description -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -63,7 +62,7 @@ fun LocationNotesButton( modifier = modifier .size(44.dp) .clip(CircleShape) - .clickable(onClickLabel = contentDescription) { onClick() }, + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), contentAlignment = Alignment.Center ) { TorAwareHeaderIcon( From 9703ebfcaf0a1d9dc412dc1c5f3950ad8848a672 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:09:37 +0200 Subject: [PATCH 16/37] Fix layout jumpiness in chat and geohash people list Three separate causes of things moving when they should not: - Chat lurched whenever a bottom sheet closed. Placement animation is meant to soften insertions and removals, but any relayout moves every item -- a sheet's text field opening the keyboard changes the chat's IME inset, and closing it changes it back. Placement animation is now armed only briefly around a real change to the message list, so items otherwise track the viewport exactly. - Anon list changed height as participants churned. Rows sized to their content, so any reorder could change the card's height; and the card sized to the live anon count, which moves constantly in a busy geohash. Rows now have an exact height, and a trimmed anon card reserves the full capped height regardless of how many are present beyond the cap. - Anons are now their own trailing section rather than a tail on each of "on location" and "teleported in", which had pushed the few recognisable names out of view twice over. Self is never grouped as an anon. Adds 7 tests covering the sectioning and the fixed-length behaviour. --- .../com/bitchat/android/ui/AboutSections.kt | 9 ++ .../bitchat/android/ui/GeohashPeopleList.kt | 129 +++++++++++------- .../bitchat/android/ui/MessageComponents.kt | 29 +++- app/src/main/res/values/strings.xml | 1 + .../android/ui/GeohashAnonOrderingTest.kt | 72 ++++++++++ 5 files changed, 191 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index 8dfb63fb..8685705e 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -73,6 +73,15 @@ internal val SheetRowLeadingSlot = 22.dp internal val SheetRowLeadingGutter = 16.dp internal val SheetRowHorizontal = 16.dp internal val SheetRowVertical = 13.dp + +/** + * Exact height of a people-list row. + * + * Fixed rather than derived from content: these lists reorder themselves constantly, and a row + * whose height depends on its content makes the whole card change height every time the order + * changes. Equals the leading glyph plus [SheetRowVertical] above and below. + */ +internal val SheetRowHeight = SheetRowLeadingSlot + SheetRowVertical * 2 internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter /** Selection indicator sized for [SheetRowLeadingSlot]. */ internal val SheetRowSelectedDot = 12.dp diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 75fe577c..bca7fe62 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.LocationOn import android.util.Log import androidx.compose.animation.AnimatedVisibility @@ -125,7 +126,15 @@ fun GeohashPeopleList( viewModel.isPersonTeleported(person.id) } - val (teleportedPeople, localPeople) = orderedPeople.partition { personTeleported(it) } + // Anonymous participants form their own trailing section rather than a tail on each + // of the others. A busy geohash is mostly anons, and splitting them across "on + // location" and "teleported in" pushed the few recognisable names out of view twice + // over. Self is never grouped as an anon even when unnamed — you always want to find + // yourself where you actually are. + val isSelf: (GeoPerson) -> Boolean = { myHex != null && it.id == myHex } + val namedPeople = orderedPeople.filter { isSelf(it) || !it.isAnonymous() } + val anonPeople = orderedPeople.filter { !isSelf(it) && it.isAnonymous() } + val (teleportedPeople, localPeople) = namedPeople.partition { personTeleported(it) } @Composable fun personRow(person: GeoPerson) { @@ -164,6 +173,17 @@ fun GeohashPeopleList( ) PeopleCard(people = teleportedPeople, row = { personRow(it) }) } + + if (anonPeople.isNotEmpty()) { + SheetIconSectionHeader( + icon = Icons.Outlined.Person, + title = stringResource(R.string.section_anonymous), + modifier = Modifier.padding( + top = if (localPeople.isNotEmpty() || teleportedPeople.isNotEmpty()) 20.dp else 0.dp + ) + ) + PeopleCard(people = anonPeople, capped = true, row = { personRow(it) }) + } } } } @@ -183,26 +203,29 @@ internal fun GeoPerson.isAnonymous(): Boolean { } /** - * One grouped card of people, with a cap on how many anonymous participants are shown. + * One grouped card of people. * - * A popular geohash can hold dozens of anons, which pushed everyone worth recognising off screen and - * turned the sheet into a wall of near-identical rows. Named participants are always listed in full; - * anons are trimmed to [MaxVisibleAnons], and the overflow is collapsed behind a count. The last - * visible anon fades out under a gradient so the truncation is legible as truncation rather than - * looking like the list simply ended. + * When [capped] the list is trimmed to [MaxVisibleAnons] rows and the remainder is collapsed behind + * a count. That matters for the anonymous section: a popular geohash can hold dozens of anons, which + * pushed everyone worth recognising off screen and turned the sheet into a wall of near-identical + * rows. + * + * The capped card is a **fixed height** — [MaxVisibleAnons] rows plus the overflow line, always, + * regardless of how many anons are currently present beyond the cap. Anons join and leave a busy + * geohash constantly, and sizing to the live count made the card grow and shrink under the reader + * every few seconds. */ @Composable private fun PeopleCard( people: List, - row: @Composable (GeoPerson) -> Unit + row: @Composable (GeoPerson) -> Unit, + capped: Boolean = false ) { val palette = LocalBitchatPalette.current - val named = people.filterNot { it.isAnonymous() } - val anons = people.filter { it.isAnonymous() } - val visibleAnons = anons.take(MaxVisibleAnons) - val hiddenAnonCount = anons.size - visibleAnons.size - val visible = named + visibleAnons + val visible = if (capped) people.take(MaxVisibleAnons) else people + val hiddenCount = people.size - visible.size + val isTrimmed = capped && people.size > MaxVisibleAnons Surface( modifier = Modifier @@ -213,50 +236,57 @@ private fun PeopleCard( shape = AboutCardShape ) { Column { - AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> - Column { - if (index > 0) SheetCardDivider() - if (hiddenAnonCount > 0 && index == visible.lastIndex) { - // Fade only the final row, so the gradient reads as "the list continues" - // rather than dimming content that is still meant to be read. - Box { - row(person) - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - listOf( - palette.surface.copy(alpha = 0f), - palette.surface.copy(alpha = 0.85f) + Box( + // Reserve the full capped height up front so the card cannot resize as anons + // churn. Rows are a fixed height, so this is exact rather than an estimate. + modifier = if (isTrimmed) { + Modifier.height(SheetRowHeight * MaxVisibleAnons) + } else { + Modifier + } + ) { + AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + if (isTrimmed && index == visible.lastIndex) { + // Fade only the final row, so the gradient reads as "the list + // continues" rather than dimming content still meant to be read. + Box { + row(person) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + listOf( + palette.surface.copy(alpha = 0f), + palette.surface.copy(alpha = 0.85f) + ) ) ) - ) - ) + ) + } + } else { + row(person) } - } else { - row(person) } } } - AnimatedVisibility( - visible = hiddenAnonCount > 0, - enter = fadeIn(tween(BitchatMotion.STANDARD_MS)), - exit = fadeOut(tween(BitchatMotion.QUICK_MS)) - ) { - Text( - text = stringResource(R.string.people_n_more, hiddenAnonCount), + if (isTrimmed) { + // Always laid out when trimmed, so the count changing never moves anything. + // Only the number itself animates. + AnimatedCountLabel( + count = hiddenCount, + text = stringResource(R.string.people_n_more, hiddenCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = palette.textTertiary, - modifier = Modifier - .fillMaxWidth() - .padding( - start = SheetRowHorizontal, - end = SheetRowHorizontal, - bottom = SheetRowVertical - ) + modifier = Modifier.padding( + start = SheetRowHorizontal, + end = SheetRowHorizontal, + bottom = SheetRowVertical + ) ) } } @@ -298,8 +328,11 @@ private fun GeohashPersonItem( Row( modifier = Modifier .fillMaxWidth() + // Exact height, not padding: a row that sizes to its content makes the card change + // height whenever the list reorders. + .height(SheetRowHeight) .clickable(onClick = onTap) - .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + .padding(horizontal = SheetRowHorizontal), verticalAlignment = Alignment.CenterVertically ) { Box( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index aac25e91..31db2ef1 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -72,6 +72,7 @@ import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle +import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Locale @@ -106,6 +107,14 @@ private val MessagePlacementSpec: FiniteAnimationSpec = spring( /** Removals are not worth dwelling on. */ private val MessageFadeOutSpec: FiniteAnimationSpec = tween(BitchatMotion.QUICK_MS) +/** + * How long placement animation stays armed after the list gains or loses a message. + * + * Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut + * short. + */ +private const val PlacementArmWindowMs = 600L + /** * Above this many simultaneous arrivals, entry animations are skipped. * @@ -226,6 +235,24 @@ fun MessagesList( arrivalTracker.arrivals(messages) } + // Placement animation exists to soften insertions and removals. But *any* relayout moves every + // item — the keyboard opening behind a bottom sheet, that sheet closing again, the composer + // growing a line — and animating those made the whole conversation lurch. So it is armed only + // briefly around a genuine change to the list, and is otherwise off, letting items track the + // viewport exactly. + var placementArmed by remember { mutableStateOf(false) } + var previousMessageCount by remember { mutableStateOf(null) } + LaunchedEffect(messages.size) { + val previous = previousMessageCount + previousMessageCount = messages.size + // Skip the first composition: the list settling into its initial padding is not a change + // worth animating. + if (previous == null || previous == messages.size) return@LaunchedEffect + placementArmed = true + delay(PlacementArmWindowMs) + placementArmed = false + } + val layoutDirection = LocalLayoutDirection.current LazyColumn( state = listState, @@ -294,7 +321,7 @@ fun MessagesList( // Entry fade is handled by entryModifier, together with the slide, so the // two cannot drift out of step. fadeInSpec = null, - placementSpec = MessagePlacementSpec, + placementSpec = if (placementArmed) MessagePlacementSpec else null, fadeOutSpec = MessageFadeOutSpec ) .then(entryModifier) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 275c5f79..357eedbd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -316,6 +316,7 @@ People (%1$d) On location Teleported in + Anonymous %1$d more\u2026 Grant location permission diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt index 8a00d1c8..342e03b0 100644 --- a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt @@ -102,6 +102,78 @@ class GeohashAnonOrderingTest { assertEquals(12 - MaxVisibleAnons, anons.size - visible.size) } + // MARK: - Sectioning + + /** Mirrors the grouping in GeohashPeopleList: self is never treated as an anon. */ + private fun sections(people: List, myId: String?): Triple, List, List> { + val isSelf: (GeoPerson) -> Boolean = { myId != null && it.id == myId } + val named = people.filter { isSelf(it) || !it.isAnonymous() } + val anons = people.filter { !isSelf(it) && it.isAnonymous() } + return Triple( + named.map { it.displayName }, + anons.map { it.displayName }, + people.map { it.displayName } + ) + } + + @Test + fun `anons are grouped out of the named sections entirely`() { + val people = listOf(person("alice"), person("anon1"), person("bob"), person("anon2")) + val (named, anons, _) = sections(people, myId = null) + + assertEquals(listOf("alice", "bob"), named) + assertEquals(listOf("anon1", "anon2"), anons) + } + + @Test + fun `self stays in the named sections even when unnamed`() { + // You always want to find yourself where you actually are, not buried in the anon section. + val me = person("anon") + val people = listOf(me, person("alice"), person("anon2")) + val (named, anons, _) = sections(people, myId = me.id) + + assertTrue("self must not be grouped as an anon", named.contains("anon")) + assertFalse(anons.contains("anon")) + assertEquals(listOf("anon2"), anons) + } + + @Test + fun `a list of only anons yields no named section`() { + val people = (1..4).map { person("anon$it") } + val (named, anons, _) = sections(people, myId = null) + + assertTrue(named.isEmpty()) + assertEquals(4, anons.size) + } + + // MARK: - Stable length + + @Test + fun `a trimmed anon list always renders exactly the cap`() { + // The reserved height is MaxVisibleAnons rows whenever trimmed, so the card cannot resize + // as anons churn above the cap. + for (total in listOf(MaxVisibleAnons + 1, MaxVisibleAnons + 7, MaxVisibleAnons + 40)) { + val anons = (1..total).map { person("anon$it") } + val visible = anons.take(MaxVisibleAnons) + assertEquals( + "row count must not depend on how many anons are present beyond the cap", + MaxVisibleAnons, + visible.size + ) + assertEquals(total - MaxVisibleAnons, anons.size - visible.size) + } + } + + @Test + fun `reordering never changes how many rows are rendered`() { + val anons = (1..9).map { person("anon$it", secondsAgo = it.toLong()) } + val byRecency = anons.sortedByDescending { it.lastSeen }.take(MaxVisibleAnons) + val reversed = anons.sortedBy { it.lastSeen }.take(MaxVisibleAnons) + + assertEquals(byRecency.size, reversed.size) + assertEquals(MaxVisibleAnons, byRecency.size) + } + @Test fun `a short anon list is shown in full with nothing hidden`() { val people = listOf(person("alice"), person("anon1"), person("anon2")) From bcfc33f35c38c955e3beab97812a17fe506f28d3 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:33:29 +0200 Subject: [PATCH 17/37] Group geohash people as People and Anon Replaces the "on location" / "teleported in" / "anonymous" split with two sections: peers who announced a nickname, then the anons. Teleport state was never worth a section of its own -- every row already carries it as a distinct glyph -- and splitting on it fragmented the short list people actually read, in a channel where most participants are anonymous anyway. Self stays in the People section even when unnamed. --- .../bitchat/android/ui/GeohashPeopleList.kt | 45 ++++++++----------- app/src/main/res/values/strings.xml | 5 +-- .../android/ui/GeohashAnonOrderingTest.kt | 11 +++-- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index bca7fe62..1196bf32 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.LocationOn import android.util.Log @@ -59,8 +60,8 @@ fun GeohashPeopleList( Column(modifier = modifier) { if (geohashPeople.isEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.LocationOn, - title = stringResource(R.string.section_on_location) + icon = Icons.Outlined.Person, + title = stringResource(R.string.section_people) ) Surface( modifier = Modifier @@ -126,15 +127,18 @@ fun GeohashPeopleList( viewModel.isPersonTeleported(person.id) } - // Anonymous participants form their own trailing section rather than a tail on each - // of the others. A busy geohash is mostly anons, and splitting them across "on - // location" and "teleported in" pushed the few recognisable names out of view twice - // over. Self is never grouped as an anon even when unnamed — you always want to find - // yourself where you actually are. + // Two groups: peers who announced a nickname, then the anons. + // + // A busy geohash is mostly anonymous drive-by participants, and mixing them in pushed + // the few recognisable names out of view. Teleport state is not a grouping any more — + // it is already on every row as its own glyph, so splitting "on location" from + // "teleported in" only fragmented the short list that people actually read. + // + // Self is never grouped as an anon even when unnamed: you always want to find yourself + // among the people, not buried at the bottom. val isSelf: (GeoPerson) -> Boolean = { myHex != null && it.id == myHex } val namedPeople = orderedPeople.filter { isSelf(it) || !it.isAnonymous() } val anonPeople = orderedPeople.filter { !isSelf(it) && it.isAnonymous() } - val (teleportedPeople, localPeople) = namedPeople.partition { personTeleported(it) } @Composable fun personRow(person: GeoPerson) { @@ -157,30 +161,19 @@ fun GeohashPeopleList( ) } - if (localPeople.isNotEmpty()) { + if (namedPeople.isNotEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.LocationOn, - title = stringResource(R.string.section_on_location) + icon = Icons.Outlined.Person, + title = stringResource(R.string.section_people) ) - PeopleCard(people = localPeople, row = { personRow(it) }) - } - - if (teleportedPeople.isNotEmpty()) { - SheetIconSectionHeader( - icon = Icons.Outlined.Explore, - title = stringResource(R.string.section_teleported_in), - modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp) - ) - PeopleCard(people = teleportedPeople, row = { personRow(it) }) + PeopleCard(people = namedPeople, row = { personRow(it) }) } if (anonPeople.isNotEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.Person, - title = stringResource(R.string.section_anonymous), - modifier = Modifier.padding( - top = if (localPeople.isNotEmpty() || teleportedPeople.isNotEmpty()) 20.dp else 0.dp - ) + icon = Icons.Outlined.HelpOutline, + title = stringResource(R.string.section_anon), + modifier = Modifier.padding(top = if (namedPeople.isNotEmpty()) 20.dp else 0.dp) ) PeopleCard(people = anonPeople, capped = true, row = { personRow(it) }) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 357eedbd..c5da7e33 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -314,9 +314,8 @@ People (%1$d) - On location - Teleported in - Anonymous + People + Anon %1$d more\u2026 Grant location permission diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt index 342e03b0..c4a76fcf 100644 --- a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt @@ -104,7 +104,10 @@ class GeohashAnonOrderingTest { // MARK: - Sectioning - /** Mirrors the grouping in GeohashPeopleList: self is never treated as an anon. */ + /** + * Mirrors the two-group split in GeohashPeopleList: peers who announced a nickname, then the + * anons. Self is never treated as an anon. + */ private fun sections(people: List, myId: String?): Triple, List, List> { val isSelf: (GeoPerson) -> Boolean = { myId != null && it.id == myId } val named = people.filter { isSelf(it) || !it.isAnonymous() } @@ -117,7 +120,7 @@ class GeohashAnonOrderingTest { } @Test - fun `anons are grouped out of the named sections entirely`() { + fun `anons are grouped out of the people section entirely`() { val people = listOf(person("alice"), person("anon1"), person("bob"), person("anon2")) val (named, anons, _) = sections(people, myId = null) @@ -126,7 +129,7 @@ class GeohashAnonOrderingTest { } @Test - fun `self stays in the named sections even when unnamed`() { + fun `self stays in the people section even when unnamed`() { // You always want to find yourself where you actually are, not buried in the anon section. val me = person("anon") val people = listOf(me, person("alice"), person("anon2")) @@ -138,7 +141,7 @@ class GeohashAnonOrderingTest { } @Test - fun `a list of only anons yields no named section`() { + fun `a list of only anons yields no people section`() { val people = (1..4).map { person("anon$it") } val (named, anons, _) = sections(people, myId = null) From fa43821cf4e7acf71d1f8eb7765c06823c8e95fc Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:36:09 +0200 Subject: [PATCH 18/37] Key message list state per conversation Switching channels reused every piece of state in MessagesList, because none of it was keyed on which conversation was being shown: - The LazyListState carried the previous channel's scroll offset, so the new channel opened at a stale position and then corrected itself. - hasScrolledToInitialPosition and followIncomingMessages carried over, so a channel entered after scrolling up in another one did not land on its newest message at all. - The arrival tracker had never seen the incoming channel's ids, so a backlog of six or fewer messages was treated as six simultaneous arrivals and each one slid in. - previousMessageCount carried over, arming placement animation for the relayout that the switch itself caused. All of it is now keyed on a conversationKey derived the same way displayMessages is. The tracker also detects a list sharing no ids with the previous one and adopts it silently, which covers /clear and any caller that does not supply a distinct key. Adds 4 tests for wholesale replacement, including the case that the burst cap cannot catch on its own. --- .../java/com/bitchat/android/ui/ChatScreen.kt | 15 ++++++ .../android/ui/LocationChannelsSheet.kt | 50 +++++++++++++++++++ .../bitchat/android/ui/MeshPeerListSheet.kt | 1 + .../bitchat/android/ui/MessageComponents.kt | 48 ++++++++++++++---- .../android/ui/LocationChannelsSheetTest.kt | 49 ++++++++++++++++++ .../android/ui/MessageArrivalTrackerTest.kt | 45 +++++++++++++++++ 6 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index ba3c627c..746135a7 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -187,6 +187,20 @@ fun ChatScreen(viewModel: ChatViewModel) { } } + // Identity of the timeline on screen, derived exactly like displayMessages above. Drives the + // per-conversation scroll position and animation state in MessagesList. + val conversationKey = when { + currentChannel != null -> "channel:$currentChannel" + else -> { + val locationChannel = selectedLocationChannel + if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) { + "geo:${locationChannel.channel.geohash}" + } else { + "mesh" + } + } + } + // Determine whether to show media buttons (only hide in geohash location chats) val showMediaButtons = when { currentChannel != null -> true @@ -229,6 +243,7 @@ fun ChatScreen(viewModel: ChatViewModel) { currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, modifier = Modifier.fillMaxSize(), + conversationKey = conversationKey, contentPadding = PaddingValues( top = statusBarHeight + headerHeight + (if (showNotesStrip) notesStripHeight else 0.dp), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 740673be..cb43cab0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -154,6 +154,9 @@ fun LocationChannelsSheet( val nearbyChannels = remember(availableChannels) { availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } } + val selectedChannelOutsideNearby = remember(selectedChannel, nearbyChannels) { + selectedLocationChannelOutsideNearby(selectedChannel, nearbyChannels) + } val showNearbyLoading = nearbyChannels.isEmpty() && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled @@ -272,6 +275,35 @@ fun LocationChannelsSheet( shape = AboutCardShape ) { Column { + selectedChannelOutsideNearby?.let { channel -> + val coverage = coverageString(channel.geohash.length) + val name = bookmarkNames[channel.geohash] + val subtitle = "#${channel.geohash} • $coverage" + + (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + ChannelOptionRow( + title = geohashTitleWithCount(channel, participantCount), + subtitle = subtitle, + isSelected = true, + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { bookmarksStore.toggle(channel.geohash) } + ) + }, + onClick = { + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + SheetCardDivider() + } + if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { nearbyChannels.forEachIndexed { index, channel -> @@ -952,6 +984,24 @@ private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelI } } +/** + * Returns the active location channel when it has no row in the nearby-channel list. + * + * This commonly happens after teleporting to a remote geohash. Keeping the selected channel in the + * main channel card makes its selection and bookmark action available even before it is bookmarked. + */ +internal fun selectedLocationChannelOutsideNearby( + selectedChannel: ChannelID?, + nearbyChannels: List +): GeohashChannel? { + val selected = (selectedChannel as? ChannelID.Location)?.channel ?: return null + return selected.takeUnless { active -> + nearbyChannels.any { nearby -> + nearby.geohash.equals(active.geohash, ignoreCase = true) + } + } +} + private fun validateGeohash(geohash: String): Boolean { if (geohash.isEmpty() || geohash.length > 12) return false val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index cfda3952..baaed09e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -887,6 +887,7 @@ fun PrivateChatSheet( currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, modifier = Modifier.weight(1f), + conversationKey = "dm:$peerID", forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { /* handle mention */ }, diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 31db2ef1..c53aa7c0 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -29,8 +29,8 @@ 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.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon @@ -42,6 +42,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -153,6 +154,12 @@ internal fun MessageArrivalTracker.arrivals(messages: List): Set return emptySet() } + // A list with nothing in common with the last one is a different conversation, not a burst of + // arrivals — /clear, or a switch the caller did not give us a distinct key for. Adopt it + // silently rather than sliding in every message at once. + val isWholesaleReplacement = + messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known } + // `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass. val added = messages.filter { known.add(it.id) } @@ -163,6 +170,7 @@ internal fun MessageArrivalTracker.arrivals(messages: List): Set } return when { + isWholesaleReplacement -> emptySet() added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet() else -> added.mapTo(HashSet(added.size)) { it.id } } @@ -181,6 +189,14 @@ fun MessagesList( * has to reserve room for their heights here rather than by shrinking the viewport. */ contentPadding: PaddingValues = PaddingValues(0.dp), + /** + * Identity of the conversation being shown — a channel, a geohash, a peer. + * + * Everything below that is per-conversation state is keyed on this. Without it, switching + * channels reused the previous conversation's scroll offset, follow flag and seen-message set, + * so the new channel opened at a stale position and then animated itself into place. + */ + conversationKey: Any? = null, forceScrollToBottom: Boolean = false, onScrolledUpChanged: ((Boolean) -> Unit)? = null, onNicknameClick: ((String) -> Unit)? = null, @@ -188,11 +204,19 @@ fun MessagesList( onCancelTransfer: ((BitchatMessage) -> Unit)? = null, onImageClick: ((String, List, Int) -> Unit)? = null ) { - val listState = rememberLazyListState() - + // A fresh scroll position per conversation. Sharing one state meant a switch inherited the + // previous channel's offset and then had to correct itself, which is what the jump was. + // + // Passing the key as an *input* rather than as `key =` is deliberate: it discards the saved + // offset on every switch, so a conversation always opens on its newest message instead of + // wherever the reader happened to be some time ago, with unseen messages below them. + val listState = rememberSaveable(conversationKey, saver = LazyListState.Saver) { + LazyListState() + } + // Track if this is the first time messages are being loaded - var hasScrolledToInitialPosition by remember { mutableStateOf(false) } - var followIncomingMessages by remember { mutableStateOf(true) } + var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) } + var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) } // Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away LaunchedEffect(messages.size) { @@ -208,7 +232,7 @@ fun MessagesList( } // Track whether user has scrolled away from the latest messages - val isAtLatest by remember { + val isAtLatest by remember(listState) { derivedStateOf { val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1 firstVisibleIndex <= 2 @@ -230,8 +254,10 @@ fun MessagesList( // Recomputed only when the list actually gains or loses a message, and synchronously, so the // arriving item can read its cue during the same composition pass in which it first appears. - val arrivalTracker = remember { MessageArrivalTracker() } - val enteringIds = remember(messages.size, messages.lastOrNull()?.id) { + // Reset per conversation, so a switch adopts the incoming messages silently instead of + // treating a whole channel's backlog as brand-new arrivals and sliding each one in. + val arrivalTracker = remember(conversationKey) { MessageArrivalTracker() } + val enteringIds = remember(conversationKey, messages.size, messages.lastOrNull()?.id) { arrivalTracker.arrivals(messages) } @@ -240,9 +266,9 @@ fun MessagesList( // growing a line — and animating those made the whole conversation lurch. So it is armed only // briefly around a genuine change to the list, and is otherwise off, letting items track the // viewport exactly. - var placementArmed by remember { mutableStateOf(false) } - var previousMessageCount by remember { mutableStateOf(null) } - LaunchedEffect(messages.size) { + var placementArmed by remember(conversationKey) { mutableStateOf(false) } + var previousMessageCount by remember(conversationKey) { mutableStateOf(null) } + LaunchedEffect(conversationKey, messages.size) { val previous = previousMessageCount previousMessageCount = messages.size // Skip the first composition: the list settling into its initial padding is not a change diff --git a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt new file mode 100644 index 00000000..e53665ca --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt @@ -0,0 +1,49 @@ +package com.bitchat.android.ui + +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LocationChannelsSheetTest { + + @Test + fun `teleported channel gets a standalone row`() { + val nearby = listOf(channel("u33dc")) + val teleported = channel("dr5ru") + + assertEquals( + teleported, + selectedLocationChannelOutsideNearby(ChannelID.Location(teleported), nearby) + ) + } + + @Test + fun `nearby selected channel is not duplicated`() { + val nearby = channel("u33dc") + + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Location(channel("U33DC")), + listOf(nearby) + ) + ) + } + + @Test + fun `mesh selection has no standalone location row`() { + assertNull( + selectedLocationChannelOutsideNearby( + ChannelID.Mesh, + listOf(channel("u33dc")) + ) + ) + } + + private fun channel(geohash: String) = GeohashChannel( + level = GeohashChannelLevel.CITY, + geohash = geohash + ) +} diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt index 0c848281..fb534e69 100644 --- a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -126,6 +126,51 @@ class MessageArrivalTrackerTest { assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a")))) } + @Test + fun `a wholesale replacement animates nothing`() { + // Switching channels, or /clear followed by fresh content: the incoming list shares no ids + // with the outgoing one, so it is a different conversation rather than a burst of arrivals. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"))) + + val other = listOf(msg("x"), msg("y")) + assertTrue( + "a different conversation must not slide every message in", + tracker.arrivals(other).isEmpty() + ) + } + + @Test + fun `a small replacement below the burst cap still animates nothing`() { + // The burst cap alone would not catch this: two messages is well under it. + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"), msg("b"), msg("c"))) + + assertTrue(tracker.arrivals(listOf(msg("x"), msg("y"))).isEmpty()) + } + + @Test + fun `a replacement that overlaps is treated as normal arrivals`() { + // Still the same conversation if anything carries over, so genuine new messages animate. + val tracker = MessageArrivalTracker() + val kept = msg("a") + tracker.arrivals(listOf(kept, msg("b"))) + + assertEquals(setOf("c"), tracker.arrivals(listOf(kept, msg("c")))) + } + + @Test + fun `after a wholesale replacement, later arrivals animate normally`() { + val tracker = MessageArrivalTracker() + tracker.arrivals(listOf(msg("a"))) + + val switched = mutableListOf(msg("x")) + tracker.arrivals(switched) // adopted silently + + switched += msg("y") + assertEquals(setOf("y"), tracker.arrivals(switched)) + } + @Test fun `the known set never outgrows the conversation`() { val tracker = MessageArrivalTracker() From 562aa787d6a93b144915d49c4f85bb48c2ff1bf9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:49:57 +0200 Subject: [PATCH 19/37] fix location channel layout --- .../android/ui/LocationChannelsSheet.kt | 201 ++++++++++-------- app/src/main/res/values/strings.xml | 3 +- 2 files changed, 115 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index cb43cab0..bd8f2b9d 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -257,53 +257,129 @@ fun LocationChannelsSheet( } } - // Location channels: globe + title, geohash subtitle, nearby levels + teleport + item(key = "location_channels_header") { + SheetIconSectionHeader( + icon = Icons.Outlined.Public, + title = stringResource(R.string.location_channels_heading), + subtitle = stringResource(R.string.location_channels_desc), + modifier = Modifier.padding(top = 20.dp) + ) + } + + selectedChannelOutsideNearby?.let { channel -> + item(key = "teleported_card") { + val coverage = coverageString(channel.geohash.length) + val name = bookmarkNames[channel.geohash] + val subtitle = "#${channel.geohash} • $coverage" + + (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + + Column { + AboutSectionLabel(text = stringResource(R.string.cd_teleported)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = geohashHashTitleWithCount( + channel.geohash, + participantCount + ), + subtitle = subtitle, + isSelected = true, + participantCount = participantCount, + titleColor = standardGreen, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = isBookmarked, + onClick = { + bookmarksStore.toggle(channel.geohash) + } + ) + }, + onClick = { + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + } + } + } + } + + if (bookmarks.isNotEmpty()) { + item(key = "bookmarks_card") { + Column { + AboutSectionLabel(text = stringResource(R.string.bookmarked)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + bookmarks.forEachIndexed { index, gh -> + if (index > 0) SheetCardDivider() + val level = levelForLength(gh.length) + val channel = GeohashChannel(level = level, geohash = gh) + val coverage = coverageString(gh.length) + val name = bookmarkNames[gh] + val subtitle = "#$gh • $coverage" + + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") + val participantCount = geohashParticipantCounts[gh] ?: 0 + + ChannelOptionRow( + title = geohashHashTitleWithCount(gh, participantCount), + subtitle = subtitle, + isSelected = isChannelSelected(channel, selectedChannel), + participantCount = participantCount, + titleBold = participantCount > 0, + trailingContent = { + ChannelBookmarkButton( + bookmarked = true, + onClick = { bookmarksStore.toggle(gh) } + ) + }, + onClick = { + val inRegional = + availableChannels.any { it.geohash == gh } + locationManager.setTeleported( + !inRegional && availableChannels.isNotEmpty() + ) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + LaunchedEffect(gh) { + bookmarksStore.resolveNameIfNeeded(gh) + } + } + } + } + } + } + } + + // Nearby location channels and the control for teleporting somewhere new. item(key = "channels_card") { Column { - SheetIconSectionHeader( - icon = Icons.Outlined.Public, - title = stringResource(R.string.location_channels_heading), - subtitle = stringResource(R.string.location_channels_desc), - modifier = Modifier.padding(top = 20.dp) + AboutSectionLabel( + text = stringResource(R.string.location_channels_nearby) ) Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), + .padding(horizontal = AboutHorizontalPadding), color = palette.surface, shape = AboutCardShape ) { Column { - selectedChannelOutsideNearby?.let { channel -> - val coverage = coverageString(channel.geohash.length) - val name = bookmarkNames[channel.geohash] - val subtitle = "#${channel.geohash} • $coverage" + - (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "") - val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 - val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) - - ChannelOptionRow( - title = geohashTitleWithCount(channel, participantCount), - subtitle = subtitle, - isSelected = true, - participantCount = participantCount, - titleColor = standardGreen, - titleBold = participantCount > 0, - trailingContent = { - ChannelBookmarkButton( - bookmarked = isBookmarked, - onClick = { bookmarksStore.toggle(channel.geohash) } - ) - }, - onClick = { - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } - ) - SheetCardDivider() - } - if (locationServicesEnabled) { if (nearbyChannels.isNotEmpty()) { nearbyChannels.forEachIndexed { index, channel -> @@ -418,57 +494,6 @@ fun LocationChannelsSheet( } } - if (bookmarks.isNotEmpty()) { - item(key = "bookmarks_card") { - Column { - AboutSectionLabel(text = stringResource(R.string.bookmarked)) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - bookmarks.forEachIndexed { index, gh -> - if (index > 0) SheetCardDivider() - val level = levelForLength(gh.length) - val channel = GeohashChannel(level = level, geohash = gh) - val coverage = coverageString(gh.length) - val name = bookmarkNames[gh] - val subtitle = "#$gh • $coverage" + - (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "") - val participantCount = geohashParticipantCounts[gh] ?: 0 - - ChannelOptionRow( - title = geohashHashTitleWithCount(gh, participantCount), - subtitle = subtitle, - isSelected = isChannelSelected(channel, selectedChannel), - participantCount = participantCount, - titleBold = participantCount > 0, - trailingContent = { - ChannelBookmarkButton( - bookmarked = true, - onClick = { bookmarksStore.toggle(gh) } - ) - }, - onClick = { - val inRegional = availableChannels.any { it.geohash == gh } - locationManager.setTeleported( - !inRegional && availableChannels.isNotEmpty() - ) - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } - ) - LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } - } - } - } - } - } - } - item(key = "tor_routing") { val torProvider = remember { ArtiTorManager.getInstance() } val torAvailable = remember { torProvider.isTorAvailable() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5da7e33..d7aba32e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,7 +303,8 @@ Channels Location Channels - Chat nearby over geohash channels. Only a coarse location is shared, never exact GPS. + Chat by coarse location. Never shares exact GPS. + Nearby Offline mesh via Bluetooth. No internet required. From 7ad709c38ef464e30abae2863fe559dc2484b16e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:51:34 +0200 Subject: [PATCH 20/37] icon --- app/src/main/java/com/bitchat/android/ui/ChatHeader.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 6e79ab91..acf8b1e7 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -598,7 +598,7 @@ private fun MainHeader( } /** - * Current channel indicator: a globe for geohash channels, a Bluetooth glyph for the local mesh. + * Current channel indicator: a globe for geohash channels, a mesh 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 @@ -618,18 +618,18 @@ private fun LocationChannelsButton( val badgeText = when (val channel = selectedChannel) { // Geohashes keep the '#' because that is how they are written and typed everywhere else. is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}" - // The local mesh is not a hashtag channel, and the Bluetooth glyph already says what it - // is, so it is plain "mesh". + // The local mesh is not a hashtag channel, and the mesh glyph already says what it is, + // so it is plain "mesh". else -> stringResource(R.string.mesh_label) } val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue - // Tor status only tints the globe (location channels). Mesh Bluetooth stays blue. + // Tor status only tints the globe (location channels). The local mesh stays blue. val torVisual = if (isLocation) { rememberTorConnectionVisual(normal = channelColor) } else { TorConnectionVisual(tint = channelColor, isProgress = false) } - val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Bluetooth + val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Hub Row( verticalAlignment = Alignment.CenterVertically, From a277b5e753c5bf63803b8de2e87cae41937ef3be Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:57:03 +0200 Subject: [PATCH 21/37] location sheet --- .../com/bitchat/android/ui/LocationChannelsSheet.kt | 12 +++++++++--- app/src/main/res/values/strings.xml | 5 +++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index bd8f2b9d..97ffa061 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -67,6 +67,7 @@ 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 +import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -115,6 +116,7 @@ fun LocationChannelsSheet( val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle() val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle() + val wifiAwareEnabled by WifiAwareController.enabled.collectAsStateWithLifecycle() var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(null) } @@ -241,8 +243,12 @@ fun LocationChannelsSheet( ChannelOptionRow( title = meshTitleWithCount(viewModel), subtitle = stringResource( - R.string.location_bluetooth_subtitle, - bluetoothRangeString() + if (wifiAwareEnabled) { + R.string.location_bluetooth_wifi_subtitle + } else { + R.string.location_bluetooth_subtitle + }, + meshRangeString() ), isSelected = selectedChannel is ChannelID.Mesh, participantCount = meshCount(viewModel), @@ -1069,6 +1075,6 @@ private fun formatDistance(value: Double): String { } } -private fun bluetoothRangeString(): String = "~10–50 m" +private fun meshRangeString(): String = "~10–50m" private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d7aba32e..212f1be2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -275,7 +275,8 @@ Offline Mesh Chat Online Geohash Channels End-to-End Encryption - #bluetooth • %1$s + Bluetooth • %1$s + Bluetooth • Wifi • %1$s Image %1$d of %2$d Image unavailable @@ -305,7 +306,7 @@ Location Channels Chat by coarse location. Never shares exact GPS. Nearby - Offline mesh via Bluetooth. No internet required. + Offline mesh. No internet required. Nearby From 703fcd0e026f0ceb844e9a8ab708b345c6291c48 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:02:11 +0200 Subject: [PATCH 22/37] move location error --- .../android/ui/LocationChannelsSheet.kt | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 97ffa061..92c10264 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -187,42 +187,6 @@ fun LocationChannelsSheet( contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(0.dp) ) { - if (locationServicesEnabled && - permissionState == LocationChannelManager.PermissionState.DENIED - ) { - item(key = "permissions") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.location_permission_denied), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = palette.accentRed - ) - TextButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - }, - contentPadding = PaddingValues(0.dp) - ) { - Text( - text = stringResource(R.string.open_settings), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - } - } - // Mesh section: icon + title header, offline subtitle, then selection card item(key = "mesh_card") { Column { @@ -372,6 +336,47 @@ fun LocationChannelsSheet( } } + if (locationServicesEnabled && + permissionState == LocationChannelManager.PermissionState.DENIED + ) { + item(key = "permissions") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.location_permission_denied), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = palette.accentRed + ) + TextButton( + onClick = { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts( + "package", + context.packageName, + null + ) + } + context.startActivity(intent) + }, + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = stringResource(R.string.open_settings), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + } + } + } + } + // Nearby location channels and the control for teleporting somewhere new. item(key = "channels_card") { Column { From 8db6ccfdc3bb93d291251324b830ec4289b5f4c4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:06:00 +0200 Subject: [PATCH 23/37] fix location channel lifecycle bug --- .../android/ui/LocationChannelsSheet.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 92c10264..dd892cfc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -51,6 +51,7 @@ import androidx.activity.result.contract.ActivityResultContracts import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet @@ -588,15 +589,16 @@ fun LocationChannelsSheet( } } - DisposableEffect(isPresented, permissionState, locationServicesEnabled) { - if (isPresented && - permissionState == LocationChannelManager.PermissionState.AUTHORIZED && - locationServicesEnabled - ) { - locationManager.refreshChannels() - locationManager.beginLiveRefresh() + LifecycleResumeEffect(isPresented, locationServicesEnabled) { + if (isPresented && locationServicesEnabled) { + locationManager.enableLocationChannels() + if (locationManager.permissionState.value == + LocationChannelManager.PermissionState.AUTHORIZED + ) { + locationManager.beginLiveRefresh() + } } - onDispose { locationManager.endLiveRefresh() } + onPauseOrDispose { locationManager.endLiveRefresh() } } // Sampling management: update sampling when channels/bookmarks change From 7b03863eaaeb600b2e55832da7e336f82790f97a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:10:51 +0200 Subject: [PATCH 24/37] remove empty lable --- .../java/com/bitchat/android/ui/ChatScreen.kt | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 746135a7..58e777f0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -30,9 +30,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.zIndex import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle @@ -300,21 +297,6 @@ fun ChatScreen(viewModel: ChatViewModel) { } ) - if ( - displayMessages.isEmpty() && - isMeshTimeline && - !nearbyNotesRevealed && - locationEnabled && - locationPermissionState == - LocationChannelManager.PermissionState.AUTHORIZED && - buildingGeohash != null - ) { - NearbyNotesRevealHint( - onClick = nearbyNotesController::reveal, - modifier = Modifier.align(Alignment.Center), - ) - } - if (showNotesStrip) { NearbyNotesStrip( noteCount = nearbyNotes.size, @@ -527,30 +509,6 @@ fun ChatScreen(viewModel: ChatViewModel) { } } -@Composable -private fun NearbyNotesRevealHint( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val actionLabel = stringResource(R.string.nearby_notes_reveal) - TextButton( - onClick = onClick, - modifier = modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 24.dp) - .semantics { contentDescription = actionLabel }, - ) { - Text( - text = "📍 $actionLabel", - modifier = Modifier.clearAndSetSemantics { }, - color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - ) - } -} - @Composable private fun NearbyNotesStrip( noteCount: Int, From a85ba406377fcc87d867f4bfac8374c1301a17d8 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:27:15 +0200 Subject: [PATCH 25/37] geist mono --- .../ui/component/sheet/BitchatSheetTopBar.kt | 4 +- .../android/hotspot/HotspotActivity.kt | 8 +- .../BackgroundLocationPermissionScreen.kt | 18 +-- .../onboarding/BatteryOptimizationScreen.kt | 26 ++-- .../onboarding/BluetoothCheckScreen.kt | 16 +-- .../android/onboarding/InitializingScreen.kt | 20 +-- .../android/onboarding/LocationCheckScreen.kt | 20 +-- .../onboarding/PermissionExplanationScreen.kt | 10 +- .../com/bitchat/android/ui/AboutSections.kt | 30 ++-- .../java/com/bitchat/android/ui/AboutSheet.kt | 26 ++-- .../java/com/bitchat/android/ui/ChatHeader.kt | 5 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 6 +- .../com/bitchat/android/ui/ChatUIUtils.kt | 85 ++++++++---- .../com/bitchat/android/ui/ChatUserSheet.kt | 12 +- .../bitchat/android/ui/GeohashPeopleList.kt | 12 +- .../android/ui/GeohashPickerActivity.kt | 8 +- .../com/bitchat/android/ui/InputComponents.kt | 24 ++-- .../com/bitchat/android/ui/LinkPreviewPill.kt | 6 +- .../android/ui/LocationChannelsSheet.kt | 30 ++-- .../bitchat/android/ui/LocationNotesSheet.kt | 36 ++--- .../bitchat/android/ui/MeshPeerListSheet.kt | 16 +-- .../bitchat/android/ui/MessageComponents.kt | 28 ++-- .../com/bitchat/android/ui/MessageGrouping.kt | 15 +- .../android/ui/SecurityVerificationSheet.kt | 28 ++-- .../bitchat/android/ui/VerificationSheet.kt | 24 ++-- .../android/ui/debug/DebugSettingsSheet.kt | 130 +++++++++--------- .../android/ui/media/AudioMessageItem.kt | 4 +- .../android/ui/media/FileSendingAnimation.kt | 7 +- .../android/ui/media/FullScreenImageViewer.kt | 3 +- .../android/ui/media/ImageMessageItem.kt | 6 +- .../android/ui/media/VoiceNotePlayer.kt | 4 +- .../android/ui/theme/BitchatPalette.kt | 2 +- .../android/ui/theme/ChatVisualTokens.kt | 64 +++++++++ .../bitchat/android/ui/theme/Typography.kt | 37 ++--- .../com/bitchat/android/util/AppConstants.kt | 2 +- app/src/main/res/font/geist_mono_bold.ttf | Bin 0 -> 150492 bytes app/src/main/res/font/geist_mono_medium.ttf | Bin 0 -> 149328 bytes app/src/main/res/font/geist_mono_regular.ttf | Bin 0 -> 148516 bytes app/src/main/res/font/geist_mono_semibold.ttf | Bin 0 -> 149700 bytes app/src/main/res/raw/geist_mono_ofl.txt | 93 +++++++++++++ .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 61 ++++++++ .../bitchat/android/ui/MessageGroupingTest.kt | 10 +- 42 files changed, 590 insertions(+), 346 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt create mode 100644 app/src/main/res/font/geist_mono_bold.ttf create mode 100644 app/src/main/res/font/geist_mono_medium.ttf create mode 100644 app/src/main/res/font/geist_mono_regular.ttf create mode 100644 app/src/main/res/font/geist_mono_semibold.ttf create mode 100644 app/src/main/res/raw/geist_mono_ofl.txt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt index 333e3ae6..b8a027dc 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -10,10 +10,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.runtime.CompositionLocalProvider +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.core.ui.component.button.CloseButton @OptIn(ExperimentalMaterial3Api::class) @@ -83,7 +83,7 @@ fun BitchatSheetTitle(text: String) { text = text, style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt index 795cce34..dbd3888b 100644 --- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt +++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt @@ -30,12 +30,12 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.util.UniversalApkManager import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -112,7 +112,7 @@ fun HotspotScreen( title = { Text( text = "Share BitChat", - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) }, navigationIcon = { @@ -398,7 +398,7 @@ fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) { text = { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal ) } @@ -615,7 +615,7 @@ fun CredentialCard( Text( text = value, style = MaterialTheme.typography.bodyLarge, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt index 93101abf..50a58beb 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt @@ -28,11 +28,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -99,7 +99,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.background_location_settings_tip), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = colorScheme.onBackground.copy(alpha = 0.8f) ) @@ -140,14 +140,14 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.background_location_needs_bullets), style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.background_location_privacy_note), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.onBackground @@ -183,7 +183,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.grant_background_location), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -201,7 +201,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -213,7 +213,7 @@ fun BackgroundLocationPermissionScreen( Text( text = stringResource(R.string.battery_optimization_skip), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -234,7 +234,7 @@ private fun HeaderSection(colorScheme: ColorScheme) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -244,7 +244,7 @@ private fun HeaderSection(colorScheme: ColorScheme) { Text( text = stringResource(R.string.background_location_required_subtitle), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt index 0b3036aa..713fe7f8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt @@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext 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.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -105,7 +105,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -115,7 +115,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.battery_optimization_detected_title), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -225,7 +225,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.disable_battery_optimization), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ) ) @@ -243,7 +243,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -259,7 +259,7 @@ private fun BatteryOptimizationEnabledContent( Text( text = stringResource(R.string.battery_optimization_skip), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) ) } @@ -284,7 +284,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -294,7 +294,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.battery_optimization_disabled_title), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -322,7 +322,7 @@ private fun BatteryOptimizationCheckingContent( Text( text = stringResource(R.string.battery_optimization_success_message), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -347,7 +347,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -357,7 +357,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.battery_optimization_not_required), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -372,7 +372,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.battery_optimization_not_supported_message), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -388,7 +388,7 @@ private fun BatteryOptimizationNotSupportedContent( Text( text = stringResource(R.string.continue_btn), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt index 933656bd..3eb6eea9 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -10,11 +10,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -83,7 +83,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.bluetooth_recommended), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -114,7 +114,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.bluetooth_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -138,7 +138,7 @@ private fun BluetoothDisabledContent( Text( text = stringResource(R.string.enable_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -187,7 +187,7 @@ private fun BluetoothNotSupportedContent( Text( text = stringResource(R.string.bluetooth_not_supported), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -204,7 +204,7 @@ private fun BluetoothNotSupportedContent( Text( text = stringResource(R.string.bluetooth_unsupported_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -235,7 +235,7 @@ private fun BluetoothCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -247,7 +247,7 @@ private fun BluetoothCheckingContent( Text( text = stringResource(R.string.checking_bluetooth_status), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt index bd231afd..f7895c65 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt @@ -8,11 +8,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -63,7 +63,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -92,7 +92,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.initializing_mesh_network), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) @@ -102,7 +102,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.dot), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = alpha) ) ) @@ -127,7 +127,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.setting_up_bluetooth), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ), textAlign = TextAlign.Center @@ -136,7 +136,7 @@ fun InitializingScreen(modifier: Modifier) { Text( text = stringResource(R.string.should_take_seconds), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.6f) ), textAlign = TextAlign.Center @@ -184,7 +184,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.setup_not_complete), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -201,7 +201,7 @@ fun InitializationErrorScreen( Text( text = errorMessage, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -220,7 +220,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.try_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -234,7 +234,7 @@ fun InitializationErrorScreen( Text( text = stringResource(R.string.open_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt index 32c86c0b..3654aba8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -11,11 +11,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -80,7 +80,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_services_required), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -122,7 +122,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_explanation), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -142,7 +142,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.location_needs_bullets), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.8f) ) ) @@ -166,7 +166,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.open_location_settings), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) @@ -180,7 +180,7 @@ private fun LocationDisabledContent( Text( text = stringResource(R.string.check_again), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), modifier = Modifier.padding(vertical = 4.dp) ) @@ -209,7 +209,7 @@ private fun LocationNotAvailableContent( Text( text = stringResource(R.string.location_services_unavailable), style = MaterialTheme.typography.headlineSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.error ), @@ -226,7 +226,7 @@ private fun LocationNotAvailableContent( Text( text = stringResource(R.string.location_unavailable_explanation), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface ), modifier = Modifier.padding(16.dp), @@ -247,7 +247,7 @@ private fun LocationCheckingContent( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = colorScheme.primary ), @@ -259,7 +259,7 @@ private fun LocationCheckingContent( Text( text = stringResource(R.string.checking_location_services), style = MaterialTheme.typography.bodyLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface.copy(alpha = 0.7f) ) ) diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt index a9b61476..afd91da3 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt @@ -18,12 +18,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R /** @@ -68,7 +68,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.app_name), style = MaterialTheme.typography.headlineLarge.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 32.sp ), @@ -79,7 +79,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.about_tagline), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.7f) ) } @@ -117,7 +117,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.privacy_bullets), style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onBackground.copy(alpha = 0.8f) ) } @@ -164,7 +164,7 @@ fun PermissionExplanationScreen( Text( text = stringResource(R.string.grant_permissions), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), modifier = Modifier.padding(vertical = 4.dp) diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index 8685705e..af0cc08b 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -46,10 +46,10 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset 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.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.icon.BitChatIcon import com.bitchat.android.ui.theme.BitchatMotion @@ -108,7 +108,7 @@ internal fun AboutSectionLabel( val palette = LocalBitchatPalette.current Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, @@ -149,7 +149,7 @@ internal fun SheetIconSectionHeader( Text( text = title, fontSize = 17.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold, color = colorScheme.primary ) @@ -159,7 +159,7 @@ internal fun SheetIconSectionHeader( text = subtitle, fontSize = 12.sp, lineHeight = 17.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textSecondary ) } @@ -205,7 +205,7 @@ internal fun AboutHero( Text( text = stringResource(R.string.app_name), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = 40.sp, // Monospace at display size leaves too much air between glyphs; pull it in slightly @@ -218,7 +218,7 @@ internal fun AboutHero( Text( text = stringResource(R.string.about_tagline), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 16.sp, color = palette.textSecondary ) @@ -227,7 +227,7 @@ internal fun AboutHero( Text( text = stringResource(R.string.version_prefix, versionName), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = palette.textTertiary ) @@ -320,7 +320,7 @@ private fun AboutTabLabel( ) { Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, @@ -355,7 +355,7 @@ private fun AboutInstructionRow( ) Text( text = text, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, lineHeight = 20.sp, color = palette.textPrimary @@ -373,7 +373,7 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { Column(modifier = modifier.fillMaxWidth()) { Text( text = stringResource(R.string.about_how_to_use_heading), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colorScheme.primary, @@ -481,7 +481,7 @@ private fun AboutFeatureRow( Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp, @@ -489,7 +489,7 @@ private fun AboutFeatureRow( ) Text( text = subtitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, color = palette.textSecondary @@ -514,7 +514,7 @@ internal fun BitchatBadge( ) { Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, @@ -539,7 +539,7 @@ internal fun SheetSectionLabel( val palette = LocalBitchatPalette.current Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, @@ -608,7 +608,7 @@ internal fun SheetDestructiveButton( Box(contentAlignment = Alignment.Center) { Text( text = text.uppercase(), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.8.sp, diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 34418056..907f7673 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -52,12 +52,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -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 androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss @@ -111,7 +111,7 @@ private fun ThemeChip( ) { Text( text = label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, color = labelColor @@ -188,7 +188,7 @@ private fun SettingsToggleRow( ) { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = titleColor @@ -197,7 +197,7 @@ private fun SettingsToggleRow( } Text( text = subtitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = subtitleColor, lineHeight = 17.sp @@ -789,7 +789,7 @@ fun AboutSheet( Text( text = stringResource(R.string.tor_not_available_in_this_build), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary, modifier = Modifier.padding( start = AboutHorizontalPadding + 16.dp, @@ -825,7 +825,7 @@ fun AboutSheet( ) { Text( text = stringResource(R.string.about_difficulty), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = palette.textPrimary @@ -837,7 +837,7 @@ fun AboutSheet( powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty) ), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = palette.textSecondary ) @@ -867,7 +867,7 @@ fun AboutSheet( else -> stringResource(R.string.about_pow_desc_extreme) }, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) } @@ -911,7 +911,7 @@ fun AboutSheet( } else { stringResource(R.string.about_tor_disconnected) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = palette.textPrimary @@ -921,7 +921,7 @@ fun AboutSheet( Text( text = torStatus.lastLogLine.take(120), fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary, maxLines = 2 ) @@ -952,7 +952,7 @@ fun AboutSheet( Text( text = stringResource(R.string.about_debug_settings), fontSize = 13.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.primary ) } @@ -960,7 +960,7 @@ fun AboutSheet( Text( text = stringResource(R.string.about_footer), fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) Spacer(modifier = Modifier.height(20.dp)) @@ -1028,7 +1028,7 @@ fun PasswordPromptDialog( onValueChange = onPasswordChange, label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) }, textStyle = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = colorScheme.primary, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index acf8b1e7..2811bff4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,6 +1,5 @@ package com.bitchat.android.ui - import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* @@ -35,12 +34,12 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager 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.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.BitChatBrandButton import com.bitchat.android.net.ArtiTorManager @@ -277,7 +276,7 @@ fun NicknameEditor( onValueChange = onValueChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = HeaderTextSize ), cursorBrush = SolidColor(colorScheme.primary), diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 58e777f0..911933ba 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,4 +1,7 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition // [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel @@ -24,7 +27,6 @@ import androidx.compose.material3.IconButton 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.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp @@ -535,7 +537,7 @@ private fun NearbyNotesStrip( }, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, ) Text( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index d1415fbe..33cb0ab4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatPalette +import com.bitchat.android.ui.theme.ChatVisualTokens import java.text.SimpleDateFormat import java.util.* @@ -18,13 +19,13 @@ import java.util.* */ /** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */ -internal const val SUFFIX_ALPHA = 0.55f +internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha /** Background opacity for a mention chip referring to somebody else. */ -internal const val MENTION_CHIP_ALPHA = 0.18f +internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha /** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */ -internal const val MENTION_CHIP_ALPHA_SELF = 0.22f +internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha /** * Get RSSI-based color for signal strength visualization @@ -54,13 +55,13 @@ fun formatTextMessageSender( val builder = AnnotatedString.Builder() val isSelf = message.isFromSelf(currentUserNickname, myPeerID) val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) - val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold + val senderWeight = FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( SpanStyle( color = senderColor, - fontSize = BASE_FONT_SIZE.sp, + fontSize = ChatVisualTokens.SenderFontSize, fontWeight = senderWeight ) ) @@ -82,8 +83,8 @@ fun formatTextMessageSender( builder.pushStyle( SpanStyle( color = senderColor.copy(alpha = SUFFIX_ALPHA), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = senderWeight + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal ) ) builder.append(suffix) @@ -97,7 +98,7 @@ 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]. + * their body via [appendBodyTimestamp]. */ fun formatTextMessageMetadata( message: BitchatMessage, @@ -125,24 +126,49 @@ fun formatTextMessageMetadata( * Deliberately carries no click annotation: the timestamp is decoration, and making it * tappable would create dead zones inside the message body. */ -private fun appendTrailingTimestamp( +private fun appendTimestampText( 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") } +} + +private fun appendBodyTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = palette.textTertiary, + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) + builder.pop() +} + +private fun appendMutedTimestamp( + builder: AnnotatedString.Builder, + message: BitchatMessage, + palette: BitchatPalette, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) builder.pop() } @@ -170,7 +196,7 @@ fun formatTextMessageBody( ) if (includeTimestamp) { - appendTrailingTimestamp(builder, message, palette, timeFormatter) + appendBodyTimestamp(builder, message, palette, timeFormatter) } return builder.toAnnotatedString() } @@ -189,16 +215,16 @@ fun formatSystemMessage( val builder = AnnotatedString.Builder() builder.pushStyle( SpanStyle( - color = palette.textSecondary, - fontSize = (BASE_FONT_SIZE - 2).sp, - fontWeight = FontWeight.Normal + color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemActionFontSize, + fontWeight = FontWeight.Medium, ) ) builder.append("// ") builder.append(message.content) builder.pop() - appendTrailingTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, palette, timeFormatter) return builder.toAnnotatedString() } @@ -226,14 +252,13 @@ fun formatMessageHeaderAnnotatedString( 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 = weight + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.SemiBold, ) ) builder.append("@") @@ -254,8 +279,8 @@ fun formatMessageHeaderAnnotatedString( builder.pushStyle( SpanStyle( color = baseColor.copy(alpha = SUFFIX_ALPHA), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = weight + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal, ) ) builder.append(suffix) @@ -263,7 +288,7 @@ fun formatMessageHeaderAnnotatedString( } } - appendTrailingTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, palette, timeFormatter) return builder.toAnnotatedString() } @@ -318,8 +343,8 @@ fun colorForPeerSeed(seed: String, isDark: Boolean): Color { hue = (hue + 0.12) % 1.0 } - val saturation = if (isDark) 0.62 else 0.85 - val brightness = if (isDark) 0.92 else 0.45 + val saturation = if (isDark) 1.0 else 0.85 + val brightness = if (isDark) 1.0 else 0.45 return Color.hsv( hue = (hue * 360).toFloat(), diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index 0cfa91ba..b6bf7606 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -6,10 +6,10 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -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.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE import androidx.compose.ui.res.stringResource import com.bitchat.android.R @@ -59,7 +59,7 @@ fun ChatUserSheet( Text( text = stringResource(R.string.at_nickname, targetNickname), fontSize = 18.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) @@ -67,7 +67,7 @@ fun ChatUserSheet( Text( text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -183,7 +183,7 @@ fun ChatUserSheet( Text( text = stringResource(R.string.cancel_lower), fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } @@ -214,7 +214,7 @@ private fun UserActionRow( Text( text = title, fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, color = titleColor ) @@ -222,7 +222,7 @@ private fun UserActionRow( Text( text = subtitle, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 1196bf32..8bbe89c4 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -19,13 +19,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush -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 androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette @@ -73,7 +73,7 @@ fun GeohashPeopleList( ) { Text( text = stringResource(R.string.nobody_around), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = palette.textTertiary, modifier = Modifier.padding( @@ -272,7 +272,7 @@ private fun PeopleCard( AnimatedCountLabel( count = hiddenCount, text = stringResource(R.string.people_n_more, hiddenCount), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = palette.textTertiary, modifier = Modifier.padding( @@ -357,7 +357,7 @@ private fun GeohashPersonItem( ) { Text( text = baseName, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, @@ -368,7 +368,7 @@ private fun GeohashPersonItem( if (suffix.isNotEmpty()) { Text( text = suffix, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor.copy(alpha = SUFFIX_ALPHA) @@ -378,7 +378,7 @@ private fun GeohashPersonItem( if (isMe) { Text( text = stringResource(R.string.you_suffix), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, color = baseColor ) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index 0f2f53bb..bece684a 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -31,13 +31,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import androidx.core.view.updateLayoutParams import com.bitchat.android.geohash.Geohash @@ -186,7 +186,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { text = stringResource(R.string.pan_zoom_instruction), fontSize = 12.sp, textAlign = TextAlign.Center, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .padding(horizontal = 14.dp, vertical = 10.dp) @@ -211,7 +211,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { Text( text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location", fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier @@ -277,7 +277,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { Text( text = stringResource(R.string.select), fontSize = (BASE_FONT_SIZE - 2).sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index d673bb79..7b556fb9 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,4 +1,7 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher @@ -45,7 +48,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -98,7 +100,7 @@ class SlashCommandVisualTransformation( withStyle( style = SpanStyle( color = commandColor, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, background = commandBackground ) @@ -145,7 +147,7 @@ class MentionVisualTransformation( withStyle( style = SpanStyle( color = mentionColor, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold, // Mirrors the mention chip used in rendered messages, so what you type // looks like what everyone will see. @@ -367,7 +369,7 @@ fun MessageInput( // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( color = palette.textPrimary, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), cursorBrush = SolidColor( if (isRecording) Color.Transparent else palette.textPrimary @@ -411,7 +413,7 @@ fun MessageInput( Text( text = stringResource(R.string.type_a_message_placeholder), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = palette.textTertiary, maxLines = 1, @@ -446,7 +448,7 @@ fun MessageInput( "%02d:%02d / %02d:%02d", secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 ), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.accentRed, fontSize = (BASE_FONT_SIZE - 4).sp ) @@ -664,7 +666,7 @@ fun CommandSuggestionItem( Text( text = allCommands.joinToString(", "), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.primary, @@ -676,7 +678,7 @@ fun CommandSuggestionItem( Text( text = syntax, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = palette.textSecondary, fontSize = (BASE_FONT_SIZE - 4).sp @@ -687,7 +689,7 @@ fun CommandSuggestionItem( Text( text = suggestion.description, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = palette.textTertiary, fontSize = (BASE_FONT_SIZE - 4).sp, @@ -737,7 +739,7 @@ fun MentionSuggestionItem( Text( text = stringResource(R.string.mention_suggestion_at, suggestion), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold ), color = palette.accentOrange, @@ -749,7 +751,7 @@ fun MentionSuggestionItem( Text( text = stringResource(R.string.mention), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = palette.textTertiary, fontSize = (BASE_FONT_SIZE - 4).sp diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index cc0d3026..9b34285c 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -17,11 +17,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext 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.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.net.URL @@ -110,7 +110,7 @@ fun LinkPreviewPill( // Title - matches iOS styling Text( text = displayTitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = BASE_FONT_SIZE.sp, fontWeight = FontWeight.SemiBold, color = textColor, @@ -121,7 +121,7 @@ fun LinkPreviewPill( // Host - matches iOS styling Text( text = displayHost, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = textColor.copy(alpha = 0.6f), maxLines = 1, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index dd892cfc..4004c520 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -42,12 +42,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -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 androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE @@ -351,7 +351,7 @@ fun LocationChannelsSheet( Text( text = stringResource(R.string.location_permission_denied), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.accentRed ) TextButton( @@ -371,7 +371,7 @@ fun LocationChannelsSheet( Text( text = stringResource(R.string.open_settings), fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } @@ -495,7 +495,7 @@ fun LocationChannelsSheet( Text( text = shownError, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.accentRed, modifier = Modifier.padding( start = AboutHorizontalPadding + ChannelRowHorizontal, @@ -541,7 +541,7 @@ fun LocationChannelsSheet( Text( text = stringResource(R.string.tor_not_available_in_this_build), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary, modifier = Modifier.padding( start = AboutHorizontalPadding + ChannelRowHorizontal, @@ -683,7 +683,7 @@ private fun ChannelOptionRow( Text( text = baseTitle, fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, color = titleColor ?: palette.textPrimary ) @@ -692,7 +692,7 @@ private fun ChannelOptionRow( count = participantCount, text = count, fontSize = 11.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textSecondary ) } @@ -700,7 +700,7 @@ private fun ChannelOptionRow( Text( text = subtitle, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, lineHeight = 17.sp, color = palette.textSecondary ) @@ -759,7 +759,7 @@ private fun ChannelLoadingRow() { Text( text = stringResource(R.string.finding_nearby_channels), fontSize = 12.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textSecondary ) } @@ -822,7 +822,7 @@ private fun CustomGeohashRow( Text( text = stringResource(R.string.hash_symbol), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) @@ -833,7 +833,7 @@ private fun CustomGeohashRow( onValueChange = onGeohashChange, textStyle = TextStyle( fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.primary ), cursorBrush = SolidColor(colorScheme.primary), @@ -846,7 +846,7 @@ private fun CustomGeohashRow( Text( text = stringResource(R.string.geohash_placeholder), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) } @@ -882,7 +882,7 @@ private fun CustomGeohashRow( fontSize = 11.sp, letterSpacing = 0.8.sp, fontWeight = FontWeight.Medium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = teleportColor, modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) ) @@ -927,7 +927,7 @@ private fun ChannelSettingsToggleRow( ) { Text( text = title, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = if (enabled) palette.textPrimary else palette.textTertiary @@ -936,7 +936,7 @@ private fun ChannelSettingsToggleRow( } Text( text = subtitle, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, color = if (enabled) palette.textSecondary else palette.textTertiary diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 7292df59..c250f6a1 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -18,9 +18,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.pluralStringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -251,7 +251,7 @@ private fun LocationNotesHeader( if (name.isNotEmpty()) { Text( text = name, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = accentGreen ) @@ -262,7 +262,7 @@ private fun LocationNotesHeader( // Description Text( text = stringResource(R.string.location_notes_description), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -272,7 +272,7 @@ private fun LocationNotesHeader( Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_relays_unavailable), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -303,7 +303,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { ) { Text( text = "@$baseName", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -312,7 +312,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { Spacer(modifier = Modifier.width(6.dp)) Text( text = ts, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -324,7 +324,7 @@ private fun NoteRow(note: LocationNotesManager.Note) { // Second row: content Text( text = note.content, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface ) @@ -343,7 +343,7 @@ private fun NoRelaysRow(onRetry: () -> Unit) { ) { Text( text = stringResource(R.string.location_notes_no_relays_title), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -351,14 +351,14 @@ private fun NoRelaysRow(onRetry: () -> Unit) { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_no_relays_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.retry), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onRetry) @@ -385,7 +385,7 @@ private fun LoadingRow() { Spacer(modifier = Modifier.width(10.dp)) Text( text = stringResource(R.string.loading_location_notes), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -404,7 +404,7 @@ private fun EmptyRow() { ) { Text( text = stringResource(R.string.location_notes_empty_title), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface @@ -412,7 +412,7 @@ private fun EmptyRow() { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_empty_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -440,7 +440,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) { Spacer(modifier = Modifier.width(6.dp)) Text( text = message, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface ) @@ -448,7 +448,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.dismiss), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onDismiss) @@ -482,7 +482,7 @@ private fun LocationNotesInputSection( Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "@$baseName", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colorScheme.onSurface @@ -506,7 +506,7 @@ private fun LocationNotesInputSection( onValueChange = onDraftChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( @@ -523,7 +523,7 @@ private fun LocationNotesInputSection( Text( text = stringResource(R.string.location_notes_input_placeholder), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = colorScheme.onSurface.copy(alpha = 0.5f), modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index baaed09e..05af5f24 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -4,6 +4,7 @@ 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 com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import android.util.Log import androidx.compose.animation.AnimatedVisibility @@ -28,7 +29,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext 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 @@ -271,7 +271,7 @@ private fun ChannelRow( } else { Text( text = "#", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = palette.textTertiary @@ -283,7 +283,7 @@ private fun ChannelRow( Text( text = channel, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, color = if (isSelected) colorScheme.primary else palette.textPrimary, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, @@ -337,7 +337,7 @@ fun PeopleSection( if (connectedPeers.isEmpty()) { Text( text = stringResource(id = R.string.no_one_connected), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = palette.textTertiary, modifier = Modifier @@ -655,7 +655,7 @@ private fun PeerItem( ) { Text( text = baseName, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor, @@ -666,7 +666,7 @@ private fun PeerItem( if (suffix.isNotEmpty()) { Text( text = suffix, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, color = baseColor.copy(alpha = SUFFIX_ALPHA) @@ -741,7 +741,7 @@ private fun UnreadBadge( style = MaterialTheme.typography.labelSmall, fontSize = 10.sp, fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.Black ) } @@ -1007,7 +1007,7 @@ fun PrivateChatSheet( text = titleText, style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface ) diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index c53aa7c0..2561082a 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,6 +1,5 @@ package com.bitchat.android.ui - import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.animation.AnimatedContent @@ -54,13 +53,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService @@ -70,6 +69,7 @@ import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.ui.media.FileMessageItem import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.ChatVisualTokens import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle @@ -290,8 +290,8 @@ fun MessagesList( top = 8.dp + contentPadding.calculateTopPadding(), bottom = 12.dp + contentPadding.calculateBottomPadding() ), - // 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. + // Spacing is owned by each item. The exported transcript uses a consistent 8.dp rhythm; + // a new speaker gets additional separation from the visible sender row's top inset. verticalArrangement = Arrangement.spacedBy(0.dp), modifier = modifier, reverseLayout = true @@ -515,7 +515,7 @@ fun MessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textPrimary, ) @@ -579,7 +579,7 @@ fun MessageItem( } else { Text( text = stringResource(R.string.file_unavailable), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textTertiary ) } @@ -610,13 +610,11 @@ fun MessageItem( } ) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = MessageBodyTextStyle.copy( - fontSize = (BASE_FONT_SIZE - 2).sp, - lineHeight = (BASE_FONT_SIZE + 3).sp, - color = palette.textSecondary + style = ChatVisualTokens.SystemActionStyle.copy( + color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), ) ) } else { @@ -696,8 +694,10 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - modifier = Modifier.fillMaxWidth(), - fontFamily = FontFamily.Monospace, + modifier = Modifier + .fillMaxWidth() + .padding(top = MessageGrouping.SENDER_TOP_PADDING), + fontFamily = BitchatFontFamily, softWrap = false, overflow = TextOverflow.Ellipsis, style = MessageSenderTextStyle, @@ -725,7 +725,7 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, style = MessageBodyTextStyle.copy(color = palette.textPrimary), diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt index f03e1b27..9b3278c4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -3,24 +3,29 @@ package com.bitchat.android.ui import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.ChatVisualTokens /** * 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. + * eye only has to register a name when the speaker actually changes. The transcript uses one + * consistent item rhythm; new groups get their additional separation from the visible sender + * row's own top padding. */ 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 + val NEW_GROUP_SPACING: Dp = ChatVisualTokens.MessageItemSpacing /** Space above a continuation message inside an existing group. */ - val GROUPED_SPACING: Dp = 4.dp + val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing /** Gap between the sender label and the first line of the body. */ - val SENDER_TO_BODY_SPACING: Dp = 2.dp + val SENDER_TO_BODY_SPACING: Dp = ChatVisualTokens.SenderToBodySpacing + + /** Top inset inside a visible sender row. */ + val SENDER_TOP_PADDING: Dp = ChatVisualTokens.SenderTopPadding /** * Messages further apart than this always start a new group, even from the same sender: diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 5d3af681..d4ba21fe 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -39,12 +39,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss @@ -93,7 +93,7 @@ fun SecurityVerificationSheet( if (peerID == null) { Text( text = stringResource(R.string.fingerprint_no_peer), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) } else { @@ -156,7 +156,7 @@ private fun SecurityVerificationHeader( Text( text = stringResource(R.string.security_verification_title), style = MaterialTheme.typography.titleSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent @@ -221,7 +221,7 @@ private fun SecurityStatusCard( Text( text = displayName, style = MaterialTheme.typography.titleMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent @@ -229,7 +229,7 @@ private fun SecurityStatusCard( Text( text = statusInfo.text, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.8f) ) @@ -259,7 +259,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_start_handshake), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -275,7 +275,7 @@ private fun SecurityVerificationActions( Text( text = stringResource(R.string.fingerprint_verified_message), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.7f), modifier = Modifier.fillMaxWidth(), @@ -291,7 +291,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -305,7 +305,7 @@ private fun SecurityVerificationActions( Text( text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.7f), modifier = Modifier.fillMaxWidth(), @@ -322,7 +322,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_mark_verified), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -351,7 +351,7 @@ private fun VerificationStatusRow( Text( text = text, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = textTint @@ -377,7 +377,7 @@ private fun FingerprintBlock( Text( text = title, style = MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent.copy(alpha = 0.8f) @@ -387,7 +387,7 @@ private fun FingerprintBlock( Text( text = formatFingerprint(fingerprint), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ), color = accent, @@ -419,7 +419,7 @@ private fun FingerprintBlock( } else { Text( text = stringResource(R.string.fingerprint_pending), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily), color = Color(0xFFFF9500), modifier = Modifier.padding(16.dp) ) diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 76aed7d8..4fc5bc34 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -60,7 +60,6 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -70,6 +69,7 @@ import androidx.core.graphics.createBitmap import androidx.core.graphics.set import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss @@ -145,7 +145,7 @@ fun VerificationSheet( text = { Text( text = "My QR", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -156,7 +156,7 @@ fun VerificationSheet( text = { Text( text = "Scan", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -209,7 +209,7 @@ fun VerificationSheet( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -233,7 +233,7 @@ private fun VerificationHeader( Text( text = stringResource(R.string.verify_title).uppercase(), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) val dismiss = LocalSheetDismiss.current @@ -260,7 +260,7 @@ private fun MyQrTabContent( Text( text = stringResource(R.string.verify_my_qr_title), style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) @@ -285,7 +285,7 @@ private fun MyQrTabContent( ) { Text( text = stringResource(R.string.verify_qr_unavailable), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = Color.Black.copy(alpha = 0.6f) ) @@ -298,7 +298,7 @@ private fun MyQrTabContent( Text( text = nickname, style = MaterialTheme.typography.headlineSmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ) @@ -309,7 +309,7 @@ private fun MyQrTabContent( Text( text = stringResource(R.string.app_name).lowercase(), style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), textAlign = TextAlign.Center ) @@ -357,7 +357,7 @@ private fun ScanTabContent( Text( text = stringResource(R.string.verify_scan_prompt_friend), color = Color.White, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, modifier = Modifier .align(Alignment.BottomCenter) @@ -388,7 +388,7 @@ private fun ScanTabContent( Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.verify_camera_permission), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface ) @@ -399,7 +399,7 @@ private fun ScanTabContent( ) { Text( text = stringResource(R.string.verify_request_camera), - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index afee16ec..9c8c48b5 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -26,11 +26,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -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 androidx.compose.ui.draw.rotate +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.services.meshgraph.MeshGraphService import kotlinx.coroutines.launch @@ -67,13 +67,13 @@ fun MeshTopologySection( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93)) - Text("Mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Mesh topology", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } val nodes = snapshot.nodes val edges = snapshot.edges val empty = nodes.isEmpty() if (empty) { - Text("No gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No gossip yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { ForceDirectedMeshGraph( nodes = nodes, @@ -97,7 +97,7 @@ fun MeshTopologySection( val label = "${node.peerID.take(8)} • ${node.nickname ?: "Unknown"}" Text( text = label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f) ) @@ -128,7 +128,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6)) Text( "Distribution info", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium ) @@ -137,7 +137,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI if (info == null) { Text( "Inspecting installed package…", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -170,7 +170,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI }, contentPadding = PaddingValues(horizontal = 0.dp) ) { - Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace) + Text("Copy certificate fingerprint", fontFamily = BitchatFontFamily) } } } @@ -184,13 +184,13 @@ private fun DistributionInfoRow(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( label, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.55f) ) Text( value, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.9f) ) @@ -347,7 +347,7 @@ fun DebugSettingsSheet( item { Text( text = stringResource(R.string.debug_tools_desc), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -361,13 +361,13 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851)) - Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_verbose_logging), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) }) } Text( stringResource(R.string.debug_verbose_hint), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -392,10 +392,10 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) - Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.debug_gatt_server), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = gattServerEnabled, onCheckedChange = { manager.setGattServerEnabled(it) scope.launch { @@ -404,9 +404,9 @@ fun DebugSettingsSheet( }) } val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER } - Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_server), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxServer.toFloat(), onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) }, @@ -415,7 +415,7 @@ fun DebugSettingsSheet( ) } Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.debug_gatt_client), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = gattClientEnabled, onCheckedChange = { manager.setGattClientEnabled(it) scope.launch { @@ -424,9 +424,9 @@ fun DebugSettingsSheet( }) } val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT } - Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_client), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxClient.toFloat(), onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) }, @@ -435,9 +435,9 @@ fun DebugSettingsSheet( ) } val overallCount = connectedDevices.size - Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) + Text(stringResource(R.string.debug_max_overall), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp)) Slider( value = maxOverall.toFloat(), onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) }, @@ -447,7 +447,7 @@ fun DebugSettingsSheet( } Text( stringResource(R.string.debug_roles_hint), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f) ) @@ -461,12 +461,12 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) - Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Transports", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) Spacer(Modifier.width(8.dp)) - Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("BLE", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = bleEnabled, onCheckedChange = { manager.setBleEnabled(it) }) @@ -474,7 +474,7 @@ fun DebugSettingsSheet( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0)) Spacer(Modifier.width(8.dp)) - Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) val wifiSwitchEnabled = wifiAwareSupported Text( when { @@ -482,7 +482,7 @@ fun DebugSettingsSheet( wifiAwareAvailable -> "available" else -> "unavailable" }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -497,7 +497,7 @@ fun DebugSettingsSheet( } Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(24.dp)) - Text("Wi‑Fi Aware verbose logging", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f)) + Text("Wi‑Fi Aware verbose logging", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f)) Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) }) } } @@ -512,7 +512,7 @@ fun DebugSettingsSheet( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_packet_relay), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } @@ -648,10 +648,10 @@ fun DebugSettingsSheet( // Helper functions moved to top-level composable below to avoid scope issues // Render two blocks: Incoming and Outgoing - Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("Incoming", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Text( "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}", - fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) DrawGraphBlock( title = "Incoming", @@ -704,10 +704,10 @@ fun DebugSettingsSheet( if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } Spacer(Modifier.height(8.dp)) - Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("Outgoing", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Text( "${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}", - fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) DrawGraphBlock( title = "Outgoing", @@ -770,7 +770,7 @@ fun DebugSettingsSheet( val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState() Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) val wifiStatusText = when { !wifiAwareSupported -> "unsupported" @@ -778,12 +778,12 @@ fun DebugSettingsSheet( !wifiAwareAvailable -> "unavailable" else -> "stopped" } - Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(wifiStatusText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } if (!wifiAwareSupported) { Text( wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) ) @@ -801,21 +801,21 @@ fun DebugSettingsSheet( label = { Text("Announce") } ) } - Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) if (wifiAwareDiscovered.isEmpty()) { - Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No discoveries yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) -> - Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = BitchatFontFamily, fontSize = 12.sp) } } Divider() - Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("Connected: ${wifiAwareConnected.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp) if (wifiAwareConnected.isEmpty()) { - Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("No active sockets", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { wifiAwareConnected.entries.take(50).forEach { (peer, ip) -> - Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text("• ${peer.take(8)}… @ $ip", fontFamily = BitchatFontFamily, fontSize = 12.sp) } } } @@ -828,17 +828,17 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0)) - Text("Sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text("Sync settings", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } - Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99) - Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0) - Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49) val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) } val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) } - Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } } } @@ -849,22 +849,22 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50)) - Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_connected_devices), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() } - Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) if (connectedDevices.isEmpty()) { - Text("None", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text("None", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { connectedDevices.forEach { dev -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client) - Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } - Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { meshService.connectionManager.disconnectAddress(dev.deviceAddress) }) } @@ -881,19 +881,19 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF)) - Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_recent_scan_results), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) } if (scanResults.isEmpty()) { - Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) + Text(stringResource(R.string.debug_none), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)) } else { scanResults.forEach { res -> Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) { Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp) - Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp) + Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) } - Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { meshService.connectionManager.connectToAddress(res.deviceAddress) }) } @@ -910,15 +910,15 @@ fun DebugSettingsSheet( Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500)) - Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text(stringResource(R.string.debug_debug_console), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) - Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable { + Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable { manager.clearDebugMessages() }) } Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) { debugMessages.takeLast(100).reversed().forEach { msg -> - Text("${msg.content}", fontFamily = FontFamily.Monospace, fontSize = 11.sp) + Text("${msg.content}", fontFamily = BitchatFontFamily, fontSize = 11.sp) } } } @@ -1039,7 +1039,7 @@ private fun DrawGraphBlock( Box(Modifier.width(leftGutter).fillMaxHeight()) { Text( "p/s", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) @@ -1053,14 +1053,14 @@ private fun DrawGraphBlock( } Text( topLabel, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp) ) Text( "0", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp) @@ -1085,8 +1085,8 @@ private fun DrawGraphBlock( ) { Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp))) Column { - Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) - Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) + Text(legendTitleFor(key), fontFamily = BitchatFontFamily, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) + Text(legendMetricsFor(key), fontFamily = BitchatFontFamily, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 8722ab29..5627111d 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -13,9 +13,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import com.bitchat.android.core.ui.component.text.AnnotatedClickableText import com.bitchat.android.mesh.MeshService @@ -72,7 +72,7 @@ fun AudioMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textPrimary, ) diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt index 2ec7cd9a..51e65108 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import kotlinx.coroutines.delay @@ -96,7 +97,7 @@ fun FileSendingAnimation( androidx.compose.material3.Text( text = revealedText, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.White ), modifier = Modifier.padding(end = 2.dp) @@ -107,7 +108,7 @@ fun FileSendingAnimation( androidx.compose.material3.Text( text = stringResource(R.string.underscore), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color.White ) ) @@ -149,7 +150,7 @@ private fun FileProgressBars( androidx.compose.material3.Text( text = progressString, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = Color(0xFF00FF7F) // Matrix green ), modifier = modifier diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt index bebc1293..3544b637 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.res.stringResource +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import java.io.File @@ -101,7 +102,7 @@ fun FullScreenImageViewer(imagePaths: List, initialIndex: Int = 0, onClo text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size), color = Color.White, fontSize = 14.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + fontFamily = BitchatFontFamily ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index a0961cfc..30387944 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp -import androidx.compose.ui.text.font.FontFamily +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType @@ -70,7 +70,7 @@ fun ImageMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = palette.textPrimary, ) @@ -142,7 +142,7 @@ fun ImageMessageItem( } } } else { - Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray) + Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 5341ec1f..10109194 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui.media +import com.bitchat.android.ui.theme.BitchatFontFamily import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -18,7 +19,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.font.FontFamily @Composable fun VoiceNotePlayer( @@ -110,7 +110,7 @@ fun VoiceNotePlayer( onSeek = seekTo ) val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--" - Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp) + Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp) } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index 697567b3..dab45ff5 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -90,7 +90,7 @@ val DarkBitchatPalette = BitchatPalette( inputSurface = Color(0xFF0B0B0B), inputSurfaceFocused = Color(0xFF151515), inputButton = Color(0xFF1E1E1E), - textPrimary = Color(0xFFE8EDE8), + textPrimary = Color(0xFFF5F5F5), textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), accentGreen = Color(0xFF32D74B), diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt new file mode 100644 index 00000000..cdd4d418 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +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.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.R + +/** + * The bundled Geist Mono family used throughout the app. + * + * Keeping the fonts in the APK preserves offline behavior and guarantees that the design-spec + * metrics do not depend on which monospace family a device happens to provide. + */ +internal val BitchatFontFamily = FontFamily( + Font(R.font.geist_mono_regular, FontWeight.Normal), + Font(R.font.geist_mono_medium, FontWeight.Medium), + Font(R.font.geist_mono_semibold, FontWeight.SemiBold), + Font(R.font.geist_mono_bold, FontWeight.Bold), +) + +/** Exact typography, spacing, and opacity values exported for the chat transcript. */ +internal object ChatVisualTokens { + val MessageBodyFontSize: TextUnit = 14.sp + val MessageBodyLineHeight: TextUnit = 20.sp + val SenderFontSize: TextUnit = 14.sp + val SenderLineHeight: TextUnit = 16.sp + val SystemActionFontSize: TextUnit = 12.sp + val SystemActionLineHeight: TextUnit = 16.sp + val SystemTimeFontSize: TextUnit = 10.sp + + val MessageItemSpacing: Dp = 8.dp + val SenderTopPadding: Dp = 8.dp + val SenderToBodySpacing: Dp = 4.dp + + const val SenderSuffixAlpha: Float = 0.60f + const val HighlightAlpha: Float = 0.20f + const val MutedTextAlpha: Float = 0.50f + + val MessageBodyStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Normal, + fontSize = MessageBodyFontSize, + lineHeight = MessageBodyLineHeight, + ) + + val SenderStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = SenderFontSize, + lineHeight = SenderLineHeight, + ) + + val SystemActionStyle = TextStyle( + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + fontSize = SystemActionFontSize, + lineHeight = SystemActionLineHeight, + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt index 66236747..2002a44a 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt @@ -2,92 +2,81 @@ package com.bitchat.android.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight 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 +internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP /** * 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 -) +val MessageBodyTextStyle = ChatVisualTokens.MessageBodyStyle /** 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 -) +val MessageSenderTextStyle = ChatVisualTokens.SenderStyle // Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency val Typography = Typography( bodyLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), bodyMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = BASE_FONT_SIZE.sp, lineHeight = (BASE_FONT_SIZE + 6).sp ), bodySmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 3).sp, lineHeight = (BASE_FONT_SIZE + 1).sp ), headlineSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, 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, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold, fontSize = (BASE_FONT_SIZE + 13).sp, lineHeight = (BASE_FONT_SIZE + 21).sp ), titleLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 5).sp, lineHeight = (BASE_FONT_SIZE + 13).sp ), titleMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE + 1).sp, lineHeight = (BASE_FONT_SIZE + 7).sp ), labelLarge = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE - 1).sp, lineHeight = (BASE_FONT_SIZE + 5).sp ), labelMedium = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium, fontSize = (BASE_FONT_SIZE - 2).sp, lineHeight = (BASE_FONT_SIZE + 3).sp ), labelSmall = TextStyle( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Normal, fontSize = (BASE_FONT_SIZE - 4).sp, lineHeight = (BASE_FONT_SIZE + 1).sp diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 11df5b08..4905c927 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -120,7 +120,7 @@ object AppConstants { object UI { const val MAX_NICKNAME_LENGTH: Int = 15 - const val BASE_FONT_SIZE_SP: Int = 15 + const val BASE_FONT_SIZE_SP: Int = 14 const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L diff --git a/app/src/main/res/font/geist_mono_bold.ttf b/app/src/main/res/font/geist_mono_bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..90eb8a86551d5425fb1e965a8deff31de1792d49 GIT binary patch literal 150492 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/font/geist_mono_medium.ttf b/app/src/main/res/font/geist_mono_medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ff49ece5a3dbc6012320749a398ea6daca096aeb GIT binary patch literal 149328 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�yj3@rDuD5@3s_u9~XZY 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/font/geist_mono_regular.ttf b/app/src/main/res/font/geist_mono_regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..50c9d5a667bc397a1495b0bb45a52bef4184661c GIT binary patch literal 148516 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

qKjwHQ!ob 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%0DkIT7^Cr|?j=S6Pw9Kpm~8w?-&>9_ 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 Gk@;Vye(^v6 literal 0 HcmV?d00001 diff --git a/app/src/main/res/raw/geist_mono_ofl.txt b/app/src/main/res/raw/geist_mono_ofl.txt new file mode 100644 index 00000000..34ffeb6f --- /dev/null +++ b/app/src/main/res/raw/geist_mono_ofl.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index e35e37f1..1f9dc2b9 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -1,9 +1,16 @@ package com.bitchat.android.ui +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.ChatVisualTokens import com.bitchat.android.ui.theme.DarkBitchatPalette import com.bitchat.android.ui.theme.LightBitchatPalette +import com.bitchat.android.ui.theme.MessageBodyTextStyle +import com.bitchat.android.ui.theme.MessageSenderTextStyle import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -68,6 +75,12 @@ class ChatUIUtilsTest { ) assertEquals("hello there 00:00:00", body.text) + val timestamp = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00:00" + }.item + assertEquals(10.sp, timestamp.fontSize) + assertEquals(FontWeight.Normal, timestamp.fontWeight) + assertEquals(palette.textTertiary, timestamp.color) } @Test @@ -80,6 +93,11 @@ class ChatUIUtilsTest { ) assertEquals("mined 00:00:00 ⛨8b", body.text) + val timestampAndPow = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00:00 ⛨8b" + }.item + assertEquals(10.sp, timestampAndPow.fontSize) + assertEquals(palette.textTertiary, timestampAndPow.color) } @Test @@ -109,6 +127,17 @@ class ChatUIUtilsTest { assertEquals(palette.textPrimary, textStyle.item.color) } + @Test + fun `chat text styles match the exported type scale`() { + assertEquals(14.sp, MessageBodyTextStyle.fontSize) + assertEquals(20.sp, MessageBodyTextStyle.lineHeight) + assertEquals(FontWeight.Normal, MessageBodyTextStyle.fontWeight) + assertEquals(BitchatFontFamily, MessageBodyTextStyle.fontFamily) + assertEquals(14.sp, MessageSenderTextStyle.fontSize) + assertEquals(16.sp, MessageSenderTextStyle.lineHeight) + assertEquals(FontWeight.SemiBold, MessageSenderTextStyle.fontWeight) + } + @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, @@ -165,6 +194,7 @@ class ChatUIUtilsTest { val chip = mentionChipSpans(body).single() assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background) + assertEquals(0.2f, chip.item.background.alpha) val nameStyle: SpanStyle = body.spanStyles .first { it.start == chip.start && it.item.color == palette.accentOrange } @@ -226,6 +256,29 @@ class ChatUIUtilsTest { assertTrue(annotated.spanStyles.all { it.item.fontStyle == null }) } + @Test + fun `system action and timestamp use their exported weights sizes and opacity`() { + val annotated = formatSystemMessage( + message = message("tor restarting", sender = "system"), + palette = palette, + timeFormatter = timeFormatter, + ) + + val action = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == "// tor restarting" + }.item + val time = annotated.spanStyles.first { + annotated.text.substring(it.start, it.end) == " 00:00:00" + }.item + + assertEquals(12.sp, action.fontSize) + assertEquals(FontWeight.Medium, action.fontWeight) + assertEquals(palette.textPrimary.copy(alpha = 0.5f), action.color) + assertEquals(10.sp, time.fontSize) + assertEquals(FontWeight.Normal, time.fontWeight) + assertEquals(palette.textPrimary.copy(alpha = 0.5f), time.color) + } + // MARK: - Sender label @Test @@ -242,6 +295,11 @@ class ChatUIUtilsTest { 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) + assertEquals(14.sp, nameSpan.item.fontSize) + assertEquals(FontWeight.SemiBold, nameSpan.item.fontWeight) + assertEquals(14.sp, suffixSpan.item.fontSize) + assertEquals(FontWeight.Normal, suffixSpan.item.fontWeight) + assertEquals(ChatVisualTokens.SenderSuffixAlpha, suffixSpan.item.color.alpha) assertTrue( "suffix must be dimmer than the name", suffixSpan.item.color.alpha < nameSpan.item.color.alpha @@ -282,6 +340,8 @@ class ChatUIUtilsTest { rgbToHsv(light.red, light.green, light.blue, lightHsv) assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) + assertEquals(1.0, darkHsv[1].toDouble(), 0.01) + assertEquals(1.0, darkHsv[2].toDouble(), 0.01) } @Test @@ -302,6 +362,7 @@ class ChatUIUtilsTest { @Test fun `light palette is not the dark palette`() { assertNull(null) + assertEquals(Color(0xFFF5F5F5), DarkBitchatPalette.textPrimary) assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary) assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark) } diff --git a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt index 4ba8ffa0..eea760f7 100644 --- a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt @@ -138,13 +138,15 @@ class MessageGroupingTest { } @Test - fun `grouped rows sit tighter than the start of a new group`() { + fun `all transcript rows use the exported eight dp rhythm`() { val grouped = MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = false) val newGroup = MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = false) - assertEquals(MessageGrouping.GROUPED_SPACING, grouped) - assertEquals(MessageGrouping.NEW_GROUP_SPACING, newGroup) - assertTrue("a new speaker needs more air than a continuation", newGroup > grouped) + assertEquals(8.dp, grouped) + assertEquals(8.dp, newGroup) + assertEquals(grouped, newGroup) + assertEquals(8.dp, MessageGrouping.SENDER_TOP_PADDING) + assertEquals(4.dp, MessageGrouping.SENDER_TO_BODY_SPACING) } private fun BitchatMessage.copyWithOffset(offsetMs: Long) = From 7cef3f329ec12e90bd3192336214c2efc97054ec Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:28:35 +0200 Subject: [PATCH 26/37] timestamp no seconds --- .../java/com/bitchat/android/ui/ChatUIUtils.kt | 11 +++++++---- .../bitchat/android/ui/MessageComponents.kt | 2 +- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 18 +++++++++--------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 33cb0ab4..5f5fa778 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -21,6 +21,9 @@ import java.util.* /** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */ internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha +/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */ +internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm" + /** Background opacity for a mention chip referring to somebody else. */ internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha @@ -102,7 +105,7 @@ fun formatTextMessageSender( */ fun formatTextMessageMetadata( message: BitchatMessage, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) ): AnnotatedString { val builder = AnnotatedString.Builder() builder.pushStyle( @@ -183,7 +186,7 @@ fun formatTextMessageBody( message: BitchatMessage, currentUserNickname: String, palette: BitchatPalette, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeTimestamp: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() @@ -210,7 +213,7 @@ fun formatTextMessageBody( fun formatSystemMessage( message: BitchatMessage, palette: BitchatPalette, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) ): AnnotatedString { val builder = AnnotatedString.Builder() builder.pushStyle( @@ -240,7 +243,7 @@ fun formatMessageHeaderAnnotatedString( currentUserNickname: String, myPeerID: String, palette: BitchatPalette, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeSender: Boolean = true ): AnnotatedString { val builder = AnnotatedString.Builder() diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 2561082a..a0581a51 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -372,7 +372,7 @@ fun MessageItem( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } + val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) } Column( modifier = modifier diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 1f9dc2b9..d58d3b4a 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -28,7 +28,7 @@ import org.robolectric.RobolectricTestRunner */ @RunWith(RobolectricTestRunner::class) class ChatUIUtilsTest { - private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply { + private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } @@ -50,7 +50,7 @@ class ChatUIUtilsTest { @Test fun `text message metadata separates PoW badge with one space`() { assertEquals( - "00:00:00 ⛨12b", + "00:00 ⛨12b", formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text, ) } @@ -58,7 +58,7 @@ class ChatUIUtilsTest { @Test fun `text message metadata omits non-positive PoW difficulty`() { assertEquals( - "00:00:00", + "00:00", formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text, ) } @@ -74,9 +74,9 @@ class ChatUIUtilsTest { timeFormatter = timeFormatter, ) - assertEquals("hello there 00:00:00", body.text) + assertEquals("hello there 00:00", body.text) val timestamp = body.spanStyles.first { - body.text.substring(it.start, it.end) == " 00:00:00" + body.text.substring(it.start, it.end) == " 00:00" }.item assertEquals(10.sp, timestamp.fontSize) assertEquals(FontWeight.Normal, timestamp.fontWeight) @@ -92,9 +92,9 @@ class ChatUIUtilsTest { timeFormatter = timeFormatter, ) - assertEquals("mined 00:00:00 ⛨8b", body.text) + assertEquals("mined 00:00 ⛨8b", body.text) val timestampAndPow = body.spanStyles.first { - body.text.substring(it.start, it.end) == " 00:00:00 ⛨8b" + body.text.substring(it.start, it.end) == " 00:00 ⛨8b" }.item assertEquals(10.sp, timestampAndPow.fontSize) assertEquals(palette.textTertiary, timestampAndPow.color) @@ -240,7 +240,7 @@ class ChatUIUtilsTest { timeFormatter = timeFormatter, ).text - assertEquals("// Tor started. Routing all chats via Tor 00:00:00", text) + assertEquals("// Tor started. Routing all chats via Tor 00:00", text) } @Test @@ -268,7 +268,7 @@ class ChatUIUtilsTest { annotated.text.substring(it.start, it.end) == "// tor restarting" }.item val time = annotated.spanStyles.first { - annotated.text.substring(it.start, it.end) == " 00:00:00" + annotated.text.substring(it.start, it.end) == " 00:00" }.item assertEquals(12.sp, action.fontSize) From 352dd6e93fd2d0182e58081597536069535a5991 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:45:51 +0200 Subject: [PATCH 27/37] new icons --- app/src/main/assets/design-icons/README.md | 6 ++ .../assets/design-icons/bookmark-filled.svg | 3 + .../assets/design-icons/bookmark-outline.svg | 3 + .../main/assets/design-icons/chat-bubbles.svg | 6 ++ app/src/main/assets/design-icons/command.svg | 6 ++ app/src/main/assets/design-icons/eye-off.svg | 6 ++ app/src/main/assets/design-icons/globe.svg | 7 ++ app/src/main/assets/design-icons/lock.svg | 7 ++ app/src/main/assets/design-icons/mention.svg | 6 ++ app/src/main/assets/design-icons/panic.svg | 3 + app/src/main/assets/design-icons/people.svg | 6 ++ app/src/main/assets/design-icons/person.svg | 6 ++ app/src/main/assets/design-icons/range.svg | 6 ++ app/src/main/assets/design-icons/shuffle.svg | 3 + app/src/main/assets/design-icons/star.svg | 3 + app/src/main/assets/design-icons/teleport.svg | 5 ++ app/src/main/assets/design-icons/waveform.svg | 3 + app/src/main/assets/design-icons/wifi-off.svg | 8 +++ .../com/bitchat/android/ui/AboutSections.kt | 49 ++++++------- .../java/com/bitchat/android/ui/ChatHeader.kt | 70 ++++++++++++++++++- .../bitchat/android/ui/GeohashPeopleList.kt | 15 ++-- .../android/ui/LocationChannelsSheet.kt | 13 +++- .../bitchat/android/ui/LocationNotesButton.kt | 5 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 13 ++-- .../res/drawable/ic_spec_bookmark_filled.xml | 6 ++ .../res/drawable/ic_spec_bookmark_outline.xml | 6 ++ .../res/drawable/ic_spec_chat_bubbles.xml | 14 ++++ app/src/main/res/drawable/ic_spec_command.xml | 14 ++++ app/src/main/res/drawable/ic_spec_eye_off.xml | 7 ++ app/src/main/res/drawable/ic_spec_globe.xml | 17 +++++ app/src/main/res/drawable/ic_spec_lock.xml | 11 +++ app/src/main/res/drawable/ic_spec_mention.xml | 7 ++ app/src/main/res/drawable/ic_spec_panic.xml | 6 ++ app/src/main/res/drawable/ic_spec_people.xml | 14 ++++ app/src/main/res/drawable/ic_spec_person.xml | 14 ++++ app/src/main/res/drawable/ic_spec_range.xml | 11 +++ app/src/main/res/drawable/ic_spec_shuffle.xml | 6 ++ app/src/main/res/drawable/ic_spec_star.xml | 8 +++ .../main/res/drawable/ic_spec_teleport.xml | 9 +++ .../main/res/drawable/ic_spec_waveform.xml | 5 ++ .../main/res/drawable/ic_spec_wifi_off.xml | 12 ++++ 41 files changed, 374 insertions(+), 51 deletions(-) create mode 100644 app/src/main/assets/design-icons/README.md create mode 100644 app/src/main/assets/design-icons/bookmark-filled.svg create mode 100644 app/src/main/assets/design-icons/bookmark-outline.svg create mode 100644 app/src/main/assets/design-icons/chat-bubbles.svg create mode 100644 app/src/main/assets/design-icons/command.svg create mode 100644 app/src/main/assets/design-icons/eye-off.svg create mode 100644 app/src/main/assets/design-icons/globe.svg create mode 100644 app/src/main/assets/design-icons/lock.svg create mode 100644 app/src/main/assets/design-icons/mention.svg create mode 100644 app/src/main/assets/design-icons/panic.svg create mode 100644 app/src/main/assets/design-icons/people.svg create mode 100644 app/src/main/assets/design-icons/person.svg create mode 100644 app/src/main/assets/design-icons/range.svg create mode 100644 app/src/main/assets/design-icons/shuffle.svg create mode 100644 app/src/main/assets/design-icons/star.svg create mode 100644 app/src/main/assets/design-icons/teleport.svg create mode 100644 app/src/main/assets/design-icons/waveform.svg create mode 100644 app/src/main/assets/design-icons/wifi-off.svg create mode 100644 app/src/main/res/drawable/ic_spec_bookmark_filled.xml create mode 100644 app/src/main/res/drawable/ic_spec_bookmark_outline.xml create mode 100644 app/src/main/res/drawable/ic_spec_chat_bubbles.xml create mode 100644 app/src/main/res/drawable/ic_spec_command.xml create mode 100644 app/src/main/res/drawable/ic_spec_eye_off.xml create mode 100644 app/src/main/res/drawable/ic_spec_globe.xml create mode 100644 app/src/main/res/drawable/ic_spec_lock.xml create mode 100644 app/src/main/res/drawable/ic_spec_mention.xml create mode 100644 app/src/main/res/drawable/ic_spec_panic.xml create mode 100644 app/src/main/res/drawable/ic_spec_people.xml create mode 100644 app/src/main/res/drawable/ic_spec_person.xml create mode 100644 app/src/main/res/drawable/ic_spec_range.xml create mode 100644 app/src/main/res/drawable/ic_spec_shuffle.xml create mode 100644 app/src/main/res/drawable/ic_spec_star.xml create mode 100644 app/src/main/res/drawable/ic_spec_teleport.xml create mode 100644 app/src/main/res/drawable/ic_spec_waveform.xml create mode 100644 app/src/main/res/drawable/ic_spec_wifi_off.xml diff --git a/app/src/main/assets/design-icons/README.md b/app/src/main/assets/design-icons/README.md new file mode 100644 index 00000000..cd04de8f --- /dev/null +++ b/app/src/main/assets/design-icons/README.md @@ -0,0 +1,6 @@ +# Design-spec icons + +These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching +Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose. +Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the +call site so selected and disabled states remain theme-aware. diff --git a/app/src/main/assets/design-icons/bookmark-filled.svg b/app/src/main/assets/design-icons/bookmark-filled.svg new file mode 100644 index 00000000..a2f90c78 --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/bookmark-outline.svg b/app/src/main/assets/design-icons/bookmark-outline.svg new file mode 100644 index 00000000..7ffb105f --- /dev/null +++ b/app/src/main/assets/design-icons/bookmark-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/chat-bubbles.svg b/app/src/main/assets/design-icons/chat-bubbles.svg new file mode 100644 index 00000000..32546ff8 --- /dev/null +++ b/app/src/main/assets/design-icons/chat-bubbles.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/command.svg b/app/src/main/assets/design-icons/command.svg new file mode 100644 index 00000000..3be46195 --- /dev/null +++ b/app/src/main/assets/design-icons/command.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/eye-off.svg b/app/src/main/assets/design-icons/eye-off.svg new file mode 100644 index 00000000..1e8ad44d --- /dev/null +++ b/app/src/main/assets/design-icons/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/globe.svg b/app/src/main/assets/design-icons/globe.svg new file mode 100644 index 00000000..e0dc1a45 --- /dev/null +++ b/app/src/main/assets/design-icons/globe.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/lock.svg b/app/src/main/assets/design-icons/lock.svg new file mode 100644 index 00000000..28a95cb0 --- /dev/null +++ b/app/src/main/assets/design-icons/lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/design-icons/mention.svg b/app/src/main/assets/design-icons/mention.svg new file mode 100644 index 00000000..59f2fa19 --- /dev/null +++ b/app/src/main/assets/design-icons/mention.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/panic.svg b/app/src/main/assets/design-icons/panic.svg new file mode 100644 index 00000000..614b9070 --- /dev/null +++ b/app/src/main/assets/design-icons/panic.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/people.svg b/app/src/main/assets/design-icons/people.svg new file mode 100644 index 00000000..f0bce1af --- /dev/null +++ b/app/src/main/assets/design-icons/people.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/person.svg b/app/src/main/assets/design-icons/person.svg new file mode 100644 index 00000000..c0c93053 --- /dev/null +++ b/app/src/main/assets/design-icons/person.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/range.svg b/app/src/main/assets/design-icons/range.svg new file mode 100644 index 00000000..88c36115 --- /dev/null +++ b/app/src/main/assets/design-icons/range.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/assets/design-icons/shuffle.svg b/app/src/main/assets/design-icons/shuffle.svg new file mode 100644 index 00000000..7f054978 --- /dev/null +++ b/app/src/main/assets/design-icons/shuffle.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/star.svg b/app/src/main/assets/design-icons/star.svg new file mode 100644 index 00000000..b385fc1f --- /dev/null +++ b/app/src/main/assets/design-icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/teleport.svg b/app/src/main/assets/design-icons/teleport.svg new file mode 100644 index 00000000..4209d337 --- /dev/null +++ b/app/src/main/assets/design-icons/teleport.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/assets/design-icons/waveform.svg b/app/src/main/assets/design-icons/waveform.svg new file mode 100644 index 00000000..558ccc9d --- /dev/null +++ b/app/src/main/assets/design-icons/waveform.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/design-icons/wifi-off.svg b/app/src/main/assets/design-icons/wifi-off.svg new file mode 100644 index 00000000..5bb5a4d7 --- /dev/null +++ b/app/src/main/assets/design-icons/wifi-off.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index af0cc08b..e6da1eee 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -1,15 +1,6 @@ package com.bitchat.android.ui -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.Person -import androidx.compose.material.icons.filled.Terminal -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.annotation.DrawableRes import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.animateColorAsState @@ -45,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -122,7 +114,7 @@ internal fun AboutSectionLabel( */ @Composable internal fun SheetIconSectionHeader( - icon: ImageVector, + @DrawableRes iconRes: Int, title: String, subtitle: String? = null, modifier: Modifier = Modifier @@ -141,7 +133,7 @@ internal fun SheetIconSectionHeader( horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = null, tint = colorScheme.primary, modifier = Modifier.size(22.dp) @@ -332,7 +324,7 @@ private fun AboutTabLabel( /** One line of the "How To Use" list: an icon plus a single instruction. */ @Composable private fun AboutInstructionRow( - icon: ImageVector, + @DrawableRes iconRes: Int, text: String ) { val colorScheme = MaterialTheme.colorScheme @@ -346,7 +338,7 @@ private fun AboutInstructionRow( verticalAlignment = Alignment.Top ) { Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = null, tint = colorScheme.primary, modifier = Modifier @@ -385,31 +377,31 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { ) AboutInstructionRow( - icon = Icons.Filled.AlternateEmail, + iconRes = R.drawable.ic_spec_person, text = stringResource(R.string.about_howto_nickname) ) AboutInstructionRow( - icon = Icons.Outlined.Public, + iconRes = R.drawable.ic_spec_globe, text = stringResource(R.string.about_howto_channels) ) AboutInstructionRow( - icon = Icons.Filled.Person, + iconRes = R.drawable.ic_spec_people, text = stringResource(R.string.about_howto_people) ) AboutInstructionRow( - icon = Icons.Outlined.BookmarkBorder, + iconRes = R.drawable.ic_spec_bookmark_outline, text = stringResource(R.string.about_howto_bookmark) ) AboutInstructionRow( - icon = Icons.Filled.AlternateEmail, + iconRes = R.drawable.ic_spec_mention, text = stringResource(R.string.about_howto_mention) ) AboutInstructionRow( - icon = Icons.Filled.Terminal, + iconRes = R.drawable.ic_spec_command, text = stringResource(R.string.about_howto_commands) ) AboutInstructionRow( - icon = Icons.Filled.DeleteForever, + iconRes = R.drawable.ic_spec_waveform, text = stringResource(R.string.about_howto_panic) ) } @@ -422,22 +414,22 @@ internal fun AboutHowToUseSection(modifier: Modifier = Modifier) { internal fun AboutFeatureCard(modifier: Modifier = Modifier) { val features = listOf( Triple( - Icons.Filled.WifiOff, + R.drawable.ic_spec_wifi_off, R.string.about_offline_mesh_title, R.string.about_offline_mesh_desc ), Triple( - Icons.Outlined.Lock, + R.drawable.ic_spec_lock, R.string.about_e2e_title, R.string.about_e2e_desc ), Triple( - Icons.Outlined.Public, + R.drawable.ic_spec_globe, R.string.about_online_geohash_title, R.string.about_online_geohash_desc ), Triple( - Icons.Outlined.VisibilityOff, + R.drawable.ic_spec_eye_off, R.string.about_no_tracking_title, R.string.about_no_tracking_desc ), @@ -446,7 +438,7 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { Column(modifier = modifier.fillMaxWidth()) { features.forEach { (icon, titleRes, descRes) -> AboutFeatureRow( - icon = icon, + iconRes = icon, title = stringResource(titleRes), subtitle = stringResource(descRes) ) @@ -456,7 +448,7 @@ internal fun AboutFeatureCard(modifier: Modifier = Modifier) { @Composable private fun AboutFeatureRow( - icon: ImageVector, + @DrawableRes iconRes: Int, title: String, subtitle: String ) { @@ -471,7 +463,7 @@ private fun AboutFeatureRow( verticalAlignment = Alignment.Top ) { Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = null, tint = colorScheme.primary, modifier = Modifier @@ -617,4 +609,3 @@ internal fun SheetDestructiveButton( } } } - diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 2811bff4..50cd5ab4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -32,7 +32,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -203,6 +205,64 @@ internal fun TorAwareHeaderIcon( } } +/** Painter-resource counterpart used by the extracted Figma SVG family. */ +@Composable +internal fun TorAwareHeaderIcon( + painter: Painter, + tint: Color, + isProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + val pulse = if (isProgress) { + val transition = rememberInfiniteTransition(label = "torPainterGlow") + transition.animateFloat( + initialValue = 0.42f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "torPainterGlowPulse" + ).value + } else { + 1f + } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { + if (isProgress) { + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } + Box( + modifier = Modifier + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f } + .background(glowBrush) + ) + } + Icon( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + }, + tint = tint + ) + } +} + @Composable fun NoiseSessionIcon( sessionState: String?, @@ -342,7 +402,7 @@ fun PeerCounter( Icon( // A single silhouette rather than a crowd: at 22.dp a multi-person glyph collapses // into an indistinct blob, and the number beside it already conveys "how many". - imageVector = Icons.Filled.Person, + painter = painterResource(R.drawable.ic_spec_people), contentDescription = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants) else -> stringResource(R.string.cd_connected_peers) @@ -628,7 +688,11 @@ private fun LocationChannelsButton( } else { TorConnectionVisual(tint = channelColor, isProgress = false) } - val badgeIcon = if (isLocation) Icons.Outlined.Public else Icons.Filled.Hub + val badgeIconRes = if (isLocation) { + R.drawable.ic_spec_globe + } else { + R.drawable.ic_spec_range + } Row( verticalAlignment = Alignment.CenterVertically, @@ -645,7 +709,7 @@ private fun LocationChannelsButton( .padding(start = 0.dp, end = 6.dp) ) { TorAwareHeaderIcon( - imageVector = badgeIcon, + painter = painterResource(badgeIconRes), tint = torVisual.tint, isProgress = torVisual.isProgress, contentDescription = stringResource(R.string.cd_tor_status) diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 8bbe89c4..0efab46f 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R @@ -60,7 +61,7 @@ fun GeohashPeopleList( Column(modifier = modifier) { if (geohashPeople.isEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.Person, + iconRes = R.drawable.ic_spec_people, title = stringResource(R.string.section_people) ) Surface( @@ -163,7 +164,7 @@ fun GeohashPeopleList( if (namedPeople.isNotEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.Person, + iconRes = R.drawable.ic_spec_people, title = stringResource(R.string.section_people) ) PeopleCard(people = namedPeople, row = { personRow(it) }) @@ -171,7 +172,7 @@ fun GeohashPeopleList( if (anonPeople.isNotEmpty()) { SheetIconSectionHeader( - icon = Icons.Outlined.HelpOutline, + iconRes = R.drawable.ic_spec_teleport, title = stringResource(R.string.section_anon), modifier = Modifier.padding(top = if (namedPeople.isNotEmpty()) 20.dp else 0.dp) ) @@ -307,9 +308,9 @@ private fun GeohashPersonItem( isMe -> "face.smiling" to palette.accentOrange else -> "face.smiling" to palette.textSecondary } - val statusIcon = when (iconName) { - "face.dashed" -> Icons.Outlined.Explore - else -> Icons.Outlined.LocationOn + val statusIconRes = when (iconName) { + "face.dashed" -> R.drawable.ic_spec_teleport + else -> R.drawable.ic_spec_person } val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) @@ -341,7 +342,7 @@ private fun GeohashPersonItem( ) } else { Icon( - imageVector = statusIcon, + painter = painterResource(statusIconRes), contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", modifier = Modifier.size(22.dp), tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 4004c520..b6db766e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -192,7 +193,7 @@ fun LocationChannelsSheet( item(key = "mesh_card") { Column { SheetIconSectionHeader( - icon = Icons.Filled.Hub, + iconRes = R.drawable.ic_spec_range, title = stringResource(R.string.mesh_title), subtitle = stringResource(R.string.mesh_section_subtitle), modifier = Modifier.padding(top = 8.dp) @@ -230,7 +231,7 @@ fun LocationChannelsSheet( item(key = "location_channels_header") { SheetIconSectionHeader( - icon = Icons.Outlined.Public, + iconRes = R.drawable.ic_spec_globe, title = stringResource(R.string.location_channels_heading), subtitle = stringResource(R.string.location_channels_desc), modifier = Modifier.padding(top = 20.dp) @@ -726,7 +727,13 @@ private fun ChannelBookmarkButton( contentAlignment = Alignment.Center ) { Icon( - imageVector = if (bookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + painter = painterResource( + if (bookmarked) { + R.drawable.ic_spec_bookmark_filled + } else { + R.drawable.ic_spec_bookmark_outline + } + ), contentDescription = stringResource( if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark ), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 23157e5d..70f98d5a 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -1,7 +1,5 @@ package com.bitchat.android.ui -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Description import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -17,6 +15,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.bitchat.android.R import com.bitchat.android.geohash.ChannelID @@ -66,7 +65,7 @@ fun LocationNotesButton( contentAlignment = Alignment.Center ) { TorAwareHeaderIcon( - imageVector = Icons.Outlined.Description, + painter = painterResource(R.drawable.ic_spec_chat_bubbles), tint = torVisual.tint, isProgress = torVisual.isProgress, contentDescription = contentDescription diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 05af5f24..a09ff487 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -122,7 +123,7 @@ fun MeshPeerListSheet( item(key = "channels_section") { Column { SheetIconSectionHeader( - icon = Icons.Filled.Tag, + iconRes = R.drawable.ic_spec_chat_bubbles, title = stringResource(R.string.channels), modifier = Modifier.padding(top = 8.dp) ) @@ -321,7 +322,7 @@ fun PeopleSection( Column(modifier = modifier) { SheetIconSectionHeader( - icon = Icons.Filled.Person, + iconRes = R.drawable.ic_spec_people, title = stringResource(R.string.people_count_title, peopleCount) ) @@ -616,7 +617,7 @@ private fun PeerItem( ) } else if (showNostrGlobe) { Icon( - imageVector = Icons.Filled.Public, + painter = painterResource(R.drawable.ic_spec_globe), contentDescription = stringResource(R.string.cd_reachable_via_nostr), modifier = Modifier.size(PeerRowIconSize), tint = palette.accentPurple @@ -690,7 +691,7 @@ private fun PeerItem( contentAlignment = Alignment.Center ) { Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + painter = painterResource(R.drawable.ic_spec_star), contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", modifier = Modifier.size(PeerRowIconSize), tint = if (isFavorite) palette.accentOrange else palette.textTertiary @@ -971,7 +972,7 @@ fun PrivateChatSheet( when { isNostrPeer || isNostrReachableFavorite -> { Icon( - imageVector = Icons.Filled.Public, + painter = painterResource(R.drawable.ic_spec_globe), contentDescription = stringResource(R.string.cd_nostr_reachable), modifier = Modifier.size(14.dp), tint = Color(0xFF9C27B0) @@ -1039,7 +1040,7 @@ fun PrivateChatSheet( modifier = Modifier.size(28.dp) ) { Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + painter = painterResource(R.drawable.ic_spec_star), contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), modifier = Modifier.size(16.dp), tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) diff --git a/app/src/main/res/drawable/ic_spec_bookmark_filled.xml b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml new file mode 100644 index 00000000..2e73b458 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_bookmark_outline.xml b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml new file mode 100644 index 00000000..988fe44c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_chat_bubbles.xml b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml new file mode 100644 index 00000000..76109058 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_command.xml b/app/src/main/res/drawable/ic_spec_command.xml new file mode 100644 index 00000000..366173cd --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_command.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_eye_off.xml b/app/src/main/res/drawable/ic_spec_eye_off.xml new file mode 100644 index 00000000..2e852cf4 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_eye_off.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_globe.xml b/app/src/main/res/drawable/ic_spec_globe.xml new file mode 100644 index 00000000..b56bb3a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_globe.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_lock.xml b/app/src/main/res/drawable/ic_spec_lock.xml new file mode 100644 index 00000000..42713a11 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_mention.xml b/app/src/main/res/drawable/ic_spec_mention.xml new file mode 100644 index 00000000..a57bf9af --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_mention.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_panic.xml b/app/src/main/res/drawable/ic_spec_panic.xml new file mode 100644 index 00000000..7c876da3 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_panic.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_people.xml b/app/src/main/res/drawable/ic_spec_people.xml new file mode 100644 index 00000000..3b2d46f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_people.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_person.xml b/app/src/main/res/drawable/ic_spec_person.xml new file mode 100644 index 00000000..33c42dcb --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_person.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_range.xml b/app/src/main/res/drawable/ic_spec_range.xml new file mode 100644 index 00000000..5ba47d6c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_range.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_spec_shuffle.xml b/app/src/main/res/drawable/ic_spec_shuffle.xml new file mode 100644 index 00000000..9f431732 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_shuffle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_star.xml b/app/src/main/res/drawable/ic_spec_star.xml new file mode 100644 index 00000000..c48025ab --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_teleport.xml b/app/src/main/res/drawable/ic_spec_teleport.xml new file mode 100644 index 00000000..6d215dea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_teleport.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_waveform.xml b/app/src/main/res/drawable/ic_spec_waveform.xml new file mode 100644 index 00000000..2e0070b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_waveform.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_wifi_off.xml b/app/src/main/res/drawable/ic_spec_wifi_off.xml new file mode 100644 index 00000000..4a716e57 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi_off.xml @@ -0,0 +1,12 @@ + + + + + + + + From 34175988a5b2c0c6b66d8375109dba95c4a07990 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:49:18 +0200 Subject: [PATCH 28/37] icons --- .../main/assets/design-icons/star-filled.svg | 3 +++ .../java/com/bitchat/android/ui/ChatHeader.kt | 17 ++++++++--------- .../com/bitchat/android/ui/MeshPeerListSheet.kt | 12 ++++++++++-- .../main/res/drawable/ic_spec_star_filled.xml | 8 ++++++++ 4 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 app/src/main/assets/design-icons/star-filled.svg create mode 100644 app/src/main/res/drawable/ic_spec_star_filled.xml diff --git a/app/src/main/assets/design-icons/star-filled.svg b/app/src/main/assets/design-icons/star-filled.svg new file mode 100644 index 00000000..ebc21c4f --- /dev/null +++ b/app/src/main/assets/design-icons/star-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 50cd5ab4..f1b2fbe2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -58,13 +58,12 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette val ChatHeaderHeight = 52.dp /** - * The single icon size used by every glyph in the top bar. + * The single visible glyph size used by every icon in the top bar. * - * Previously the bar mixed 12/14/16/18.dp icons, which is why it read as a collection of - * unrelated glyphs rather than one control strip. One size for everything, and large enough to - * actually see. + * The Figma header pairs 16 px icons with compact labels. At our 17sp header scale, 19dp preserves + * that icon-to-cap-height relationship without affecting the surrounding 44dp touch targets. */ -internal val HeaderIconSize = 22.dp +internal val HeaderIconSize = 19.dp /** * Text size for the top bar's labels: nickname, channel name, peer count. @@ -392,7 +391,7 @@ fun PeerCounter( Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = modifier .clip(HeaderClusterShape) .pressScaleClickable(onClick = onClick) @@ -400,8 +399,8 @@ fun PeerCounter( .padding(horizontal = 6.dp) ) { Icon( - // A single silhouette rather than a crowd: at 22.dp a multi-person glyph collapses - // into an indistinct blob, and the number beside it already conveys "how many". + // The extracted people glyph stays legible at the compact header scale; the number + // beside it carries the precise count. painter = painterResource(R.drawable.ic_spec_people), contentDescription = when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants) @@ -696,7 +695,7 @@ private fun LocationChannelsButton( Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier .clip(HeaderClusterShape) .pressScaleClickable( diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index a09ff487..9e1639b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -691,7 +691,9 @@ private fun PeerItem( contentAlignment = Alignment.Center ) { Icon( - painter = painterResource(R.drawable.ic_spec_star), + painter = painterResource( + if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star + ), contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", modifier = Modifier.size(PeerRowIconSize), tint = if (isFavorite) palette.accentOrange else palette.textTertiary @@ -1040,7 +1042,13 @@ fun PrivateChatSheet( modifier = Modifier.size(28.dp) ) { Icon( - painter = painterResource(R.drawable.ic_spec_star), + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite), modifier = Modifier.size(16.dp), tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) diff --git a/app/src/main/res/drawable/ic_spec_star_filled.xml b/app/src/main/res/drawable/ic_spec_star_filled.xml new file mode 100644 index 00000000..d29d62d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_star_filled.xml @@ -0,0 +1,8 @@ + + + + + From fe4920465d7dab116d6930f64234b6f03f5f766c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:02:24 +0200 Subject: [PATCH 29/37] cleanup --- .../bitchat/android/ui/LocationChannelsSheet.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index b6db766e..7581dd46 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -966,10 +966,19 @@ private fun ChannelSettingsToggleRow( // MARK: - Helper Functions +/** Separates the channel name from the animated people-count label in composed titles. */ +private const val TITLE_COUNT_SEP = '\u001F' + private fun splitTitleAndCount(title: String): Pair { + val sep = title.indexOf(TITLE_COUNT_SEP) + if (sep != -1) { + return Pair(title.substring(0, sep), title.substring(sep + 1).ifEmpty { null }) + } + // Legacy "[n people]" form val lastBracketIndex = title.lastIndexOf('[') return if (lastBracketIndex != -1) { - Pair(title.substring(0, lastBracketIndex).trim(), title.substring(lastBracketIndex)) + val count = title.substring(lastBracketIndex + 1).removeSuffix("]") + Pair(title.substring(0, lastBracketIndex).trim(), count.ifEmpty { null }) } else { Pair(title, null) } @@ -981,7 +990,7 @@ private fun meshTitleWithCount(viewModel: ChatViewModel): String { val ctx = LocalContext.current val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount) val meshLabel = stringResource(R.string.mesh_title) - return "$meshLabel [$peopleText]" + return "$meshLabel$TITLE_COUNT_SEP$peopleText" } private fun meshCount(viewModel: ChatViewModel): Int { @@ -1006,7 +1015,7 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province) GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region) } - return "$levelName [$peopleText]" + return "$levelName$TITLE_COUNT_SEP$peopleText" } @Composable @@ -1019,7 +1028,7 @@ private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): S } else { ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount) } - return "#$geohash [$peopleText]" + return "#$geohash$TITLE_COUNT_SEP$peopleText" } private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean { From 4863381dbb7c600c8d8fe471ed4857a7c3414505 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:27:15 +0200 Subject: [PATCH 30/37] mentions --- .../bitchat/android/ui/CommandProcessor.kt | 38 ++++++++++--- .../com/bitchat/android/ui/InputComponents.kt | 37 +++++++++---- .../android/ui/MentionSuggestionsTest.kt | 54 +++++++++++++++++++ 3 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index a92d2d60..04c0b0d7 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import java.util.Date +import java.util.Locale /** * Handles processing of IRC-style commands @@ -448,12 +449,13 @@ class CommandProcessor( is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: use Bluetooth mesh peer nicknames - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } is com.bitchat.android.geohash.ChannelID.Location -> { // Location channel: use geohash participants with collision-resistant suffixes - val geohashPeople = viewModel.geohashPeople.value ?: emptyList() + val geohashPeople = viewModel.geohashPeople.value val currentNickname = state.getNicknameValue() geohashPeople.mapNotNull { person -> @@ -469,13 +471,11 @@ class CommandProcessor( } } else { // Fallback to mesh peers if no viewModel available - meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] } + val peerNicknames = meshService.getPeerNicknames() + peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } } - // Filter nicknames based on the text after @ - val filteredNicknames = peerCandidates.filter { nickname -> - nickname.startsWith(textAfterAt, ignoreCase = true) - }.sorted() + val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt) if (filteredNicknames.isNotEmpty()) { state.setMentionSuggestions(filteredNicknames) @@ -533,3 +533,27 @@ class CommandProcessor( } } } + +/** + * Keep mention autocomplete useful in crowded channels: a bare `anon` identity has not announced + * a username and is not actionable. Names such as `anon1234` are announced usernames and remain + * valid mention targets. + */ +internal fun filterMentionCandidates( + candidates: List, + query: String +): List { + return candidates.asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .filterNot(::isUnannouncedMentionNickname) + .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } + .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } + .sortedWith(String.CASE_INSENSITIVE_ORDER) + .toList() +} + +internal fun isUnannouncedMentionNickname(displayName: String): Boolean { + val base = splitSuffix(displayName.trim()).first + return base.equals("anon", ignoreCase = true) +} diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 7b556fb9..d0377a8f 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,6 +1,5 @@ package com.bitchat.android.ui -import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher @@ -28,6 +27,8 @@ import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -707,13 +708,18 @@ fun MentionSuggestionsBox( ) { val palette = LocalBitchatPalette.current - Column( + LazyColumn( modifier = modifier + .heightIn(max = MentionSuggestionsMaxHeight) + .clip(MentionSuggestionsShape) .background(palette.surface) - .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) - .padding(vertical = 6.dp) + .border(1.dp, palette.outlineVariant, MentionSuggestionsShape), + contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { - suggestions.forEach { suggestion: String -> + items( + items = suggestions, + key = { suggestion -> suggestion.lowercase() } + ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, onClick = { onSuggestionClick(suggestion) } @@ -732,8 +738,9 @@ fun MentionSuggestionItem( Row( modifier = Modifier .fillMaxWidth() + .height(MentionSuggestionRowHeight) .clickable { onClick() } - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -743,10 +750,13 @@ fun MentionSuggestionItem( fontWeight = FontWeight.SemiBold ), color = palette.accentOrange, - fontSize = (BASE_FONT_SIZE - 2).sp + fontSize = (BASE_FONT_SIZE - 2).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(12.dp)) Text( text = stringResource(R.string.mention), @@ -754,7 +764,16 @@ fun MentionSuggestionItem( fontFamily = BitchatFontFamily ), color = palette.textTertiary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 4).sp, + maxLines = 1 ) } } + +/** Mention autocomplete stays compact even in crowded channels. */ +internal const val MaxVisibleMentionSuggestions = 5 +private val MentionSuggestionRowHeight = 48.dp +private val MentionSuggestionsVerticalPadding = 6.dp +private val MentionSuggestionsMaxHeight = + (48 * MaxVisibleMentionSuggestions + 12).dp +private val MentionSuggestionsShape = RoundedCornerShape(8.dp) diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt new file mode 100644 index 00000000..75fb6c01 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MentionSuggestionsTest { + + @Test + fun `only users without an announced nickname are excluded from mentions`() { + val suggestions = filterMentionCandidates( + candidates = listOf( + "anon", + "anon#04af", + "anon7674#df5b", + "alice#1234", + "anonymous", + "anonracer#04af" + ), + query = "" + ) + + assertEquals( + listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"), + suggestions + ) + assertTrue(suggestions.none(::isUnannouncedMentionNickname)) + } + + @Test + fun `mention filtering is case insensitive and removes duplicates`() { + val suggestions = filterMentionCandidates( + candidates = listOf("Bob#1234", "bob#1234", "bobby#5678", "alice#9999"), + query = "BO" + ) + + assertEquals(listOf("Bob#1234", "bobby#5678"), suggestions) + } + + @Test + fun `announced names beginning with anon stay mentionable`() { + assertTrue(isUnannouncedMentionNickname("anon")) + assertTrue(isUnannouncedMentionNickname("anon#04af")) + assertFalse(isUnannouncedMentionNickname("anon1234#04af")) + assertFalse(isUnannouncedMentionNickname("anonymous#04af")) + assertFalse(isUnannouncedMentionNickname("anonracer")) + } + + @Test + fun `mention popup viewport is capped at five rows`() { + assertEquals(5, MaxVisibleMentionSuggestions) + } +} From b1d709fc4a302e51e127321a7567578729a612b0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:32:33 +0200 Subject: [PATCH 31/37] fix mentions --- .../java/com/bitchat/android/ui/ChatScreen.kt | 50 +++++++++++++---- .../com/bitchat/android/ui/InputComponents.kt | 54 +++++++++++++++++-- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 911933ba..ec3c5d4f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,6 +1,5 @@ package com.bitchat.android.ui -import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BitchatFontFamily // [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition @@ -593,14 +592,47 @@ fun ChatInputSection( ) HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) } - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { - MentionSuggestionsBox( - suggestions = mentionSuggestions, - onSuggestionClick = onMentionSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + // Retain the final populated list while the picker exits. The state layer clears + // suggestions together with visibility; without this snapshot the panel would empty and + // snap shut before its shrink/fade animation had a frame to run. + var retainedMentionSuggestions by remember { mutableStateOf(emptyList()) } + LaunchedEffect(mentionSuggestions) { + if (mentionSuggestions.isNotEmpty()) { + retainedMentionSuggestions = mentionSuggestions + } + } + val mentionPickerVisible = showMentionSuggestions && mentionSuggestions.isNotEmpty() + val displayedMentionSuggestions = mentionSuggestions.ifEmpty { + retainedMentionSuggestions + } + + AnimatedVisibility( + visible = mentionPickerVisible, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + expandFrom = Alignment.Bottom + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + animationSpec = tween( + BitchatMotion.QUICK_MS, + easing = FastOutSlowInEasing + ), + shrinkTowards = Alignment.Bottom + ) + ) { + Column { + MentionSuggestionsBox( + suggestions = displayedMentionSuggestions, + onSuggestionClick = onMentionSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + } } MessageInput( value = messageText, diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index d0377a8f..07f633dc 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -19,9 +19,11 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -700,6 +702,7 @@ fun CommandSuggestionItem( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun MentionSuggestionsBox( suggestions: List, @@ -711,6 +714,12 @@ fun MentionSuggestionsBox( LazyColumn( modifier = modifier .heightIn(max = MentionSuggestionsMaxHeight) + .animateContentSize( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) .clip(MentionSuggestionsShape) .background(palette.surface) .border(1.dp, palette.outlineVariant, MentionSuggestionsShape), @@ -722,7 +731,18 @@ fun MentionSuggestionsBox( ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, - onClick = { onSuggestionClick(suggestion) } + onClick = { onSuggestionClick(suggestion) }, + modifier = Modifier.animateItem( + fadeInSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + placementSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ), + fadeOutSpec = tween(BitchatMotion.QUICK_MS) + ) ) } } @@ -731,15 +751,41 @@ fun MentionSuggestionsBox( @Composable fun MentionSuggestionItem( suggestion: String, - onClick: () -> Unit + onClick: () -> Unit, + modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressedBackground by animateColorAsState( + targetValue = if (isPressed) { + palette.accentOrange.copy(alpha = 0.10f) + } else { + Color.Transparent + }, + animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), + label = "mentionSuggestionPressedBackground" + ) + val pressedScale by animateFloatAsState( + targetValue = if (isPressed) 0.985f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "mentionSuggestionPressedScale" + ) Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(MentionSuggestionRowHeight) - .clickable { onClick() } + .scale(pressedScale) + .background(pressedBackground) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { From c1c7704c32f8d35a8251c1e59fdf833d3e19fe07 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:46:11 +0200 Subject: [PATCH 32/37] grouping of geohash channel list --- .../design-icons/on-location-person.svg | 5 + .../core/ui/component/button/CloseButton.kt | 5 +- .../com/bitchat/android/ui/ChatUIUtils.kt | 10 + .../bitchat/android/ui/CommandProcessor.kt | 7 +- .../bitchat/android/ui/GeohashPeopleList.kt | 314 +++++++----------- .../drawable/ic_spec_on_location_person.xml | 13 + app/src/main/res/values/strings.xml | 4 +- .../android/ui/GeohashAnonOrderingTest.kt | 188 ----------- .../android/ui/GeohashPresenceGroupingTest.kt | 83 +++++ .../android/ui/MentionSuggestionsTest.kt | 12 +- 10 files changed, 249 insertions(+), 392 deletions(-) create mode 100644 app/src/main/assets/design-icons/on-location-person.svg create mode 100644 app/src/main/res/drawable/ic_spec_on_location_person.xml delete mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt diff --git a/app/src/main/assets/design-icons/on-location-person.svg b/app/src/main/assets/design-icons/on-location-person.svg new file mode 100644 index 00000000..5b448ae9 --- /dev/null +++ b/app/src/main/assets/design-icons/on-location-person.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 36ad7526..091f93fc 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitchat.android.R @@ -24,8 +25,8 @@ fun CloseButton( // 44.dp to match every other tap target in the app's chrome. modifier = modifier.size(44.dp), colors = IconButtonDefaults.iconButtonColors( - contentColor = palette.textSecondary, - containerColor = palette.surfaceVariant + contentColor = palette.accentGreen, + containerColor = Color.Transparent ) ) { Icon( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 5f5fa778..05425f60 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -373,6 +373,16 @@ fun splitSuffix(name: String): Pair { return Pair(name, "") } +/** + * A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport + * may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as + * `anon1234`, `anonymous`, and `anonracer` are real announced usernames. + */ +internal fun isUnannouncedNickname(displayName: String): Boolean { + val base = splitSuffix(displayName.trim()).first + return base.equals("anon", ignoreCase = true) +} + /** * iOS-style content formatting with proper hashtag and mention handling. * diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 04c0b0d7..4ac9344a 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -546,14 +546,9 @@ internal fun filterMentionCandidates( return candidates.asSequence() .map(String::trim) .filter(String::isNotEmpty) - .filterNot(::isUnannouncedMentionNickname) + .filterNot(::isUnannouncedNickname) .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } .sortedWith(String.CASE_INSENSITIVE_ORDER) .toList() } - -internal fun isUnannouncedMentionNickname(displayName: String): Boolean { - val base = splitSuffix(displayName.trim()).first - return base.equals("anon", ignoreCase = true) -} diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 0efab46f..7610fedf 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -2,23 +2,13 @@ package com.bitchat.android.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.outlined.Explore -import androidx.compose.material.icons.outlined.HelpOutline -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.LocationOn import android.util.Log -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -28,7 +18,6 @@ import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R -import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -48,22 +37,74 @@ fun GeohashPeopleList( onTapPerson: () -> Unit, modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle() + val teleportedGeo by viewModel.teleportedGeo.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val palette = LocalBitchatPalette.current + val myHex = remember(selectedLocationChannel) { + when (val channel = selectedLocationChannel) { + is com.bitchat.android.geohash.ChannelID.Location -> { + try { + val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( + forGeohash = channel.channel.geohash, + context = viewModel.getApplication() + ) + identity.publicKeyHex.lowercase(Locale.ROOT) + } catch (e: Exception) { + Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}") + null + } + } + else -> null + } + } + val peopleIncludingSelf = remember(geohashPeople, myHex, nickname) { + if (myHex != null && geohashPeople.none { it.id.equals(myHex, ignoreCase = true) }) { + listOf( + GeoPerson( + id = myHex, + displayName = nickname.ifBlank { "anon" }, + lastSeen = Date(0) + ) + ) + geohashPeople + } else { + geohashPeople + } + } + val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) { + sectionGeohashPeople( + people = peopleIncludingSelf, + myId = myHex, + selfIsTeleported = isTeleported, + teleportedIds = teleportedGeo + ) + } + val displayedPeople = remember(sections) { + sections.onLocation + sections.teleportedIn + } + val teleportedPersonIds = remember(sections.teleportedIn) { + sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) } + } + val baseNameCounts = remember(displayedPeople) { + buildMap { + displayedPeople.forEach { person -> + val baseName = splitSuffix(person.displayName).first + put(baseName, (get(baseName) ?: 0) + 1) + } + } + } Column(modifier = modifier) { - if (geohashPeople.isEmpty()) { - SheetIconSectionHeader( - iconRes = R.drawable.ic_spec_people, - title = stringResource(R.string.section_people) - ) + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, displayedPeople.size) + ) + + if (displayedPeople.isEmpty()) { Surface( modifier = Modifier .fillMaxWidth() @@ -84,77 +125,23 @@ fun GeohashPeopleList( ) } } else { - val myHex = remember(selectedLocationChannel) { - when (val channel = selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> { - try { - val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity( - forGeohash = channel.channel.geohash, - context = viewModel.getApplication() - ) - identity.publicKeyHex.lowercase() - } catch (e: Exception) { - Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}") - null - } - } - else -> null - } - } - - // Self first, then anyone who chose a nickname, then the anons — all by recency - // within their group. A busy geohash is mostly anonymous drive-by participants, and - // letting them sort by recency alone buried the handful of people worth recognising. - val orderedPeople = remember(geohashPeople, myHex) { - geohashPeople.sortedWith( - compareByDescending { myHex != null && it.id == myHex } - .thenBy { it.isAnonymous() } - .thenByDescending { it.lastSeen } - ) - } - - val baseNameCounts = remember(geohashPeople) { - val counts = mutableMapOf() - geohashPeople.forEach { person -> - val (b, _) = splitSuffix(person.displayName) - counts[b] = (counts[b] ?: 0) + 1 - } - counts - } - - fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) { - isTeleported - } else { - viewModel.isPersonTeleported(person.id) - } - - // Two groups: peers who announced a nickname, then the anons. - // - // A busy geohash is mostly anonymous drive-by participants, and mixing them in pushed - // the few recognisable names out of view. Teleport state is not a grouping any more — - // it is already on every row as its own glyph, so splitting "on location" from - // "teleported in" only fragmented the short list that people actually read. - // - // Self is never grouped as an anon even when unnamed: you always want to find yourself - // among the people, not buried at the bottom. - val isSelf: (GeoPerson) -> Boolean = { myHex != null && it.id == myHex } - val namedPeople = orderedPeople.filter { isSelf(it) || !it.isAnonymous() } - val anonPeople = orderedPeople.filter { !isSelf(it) && it.isAnonymous() } - @Composable fun personRow(person: GeoPerson) { + val isMe = myHex != null && person.id.equals(myHex, ignoreCase = true) + val personIsTeleported = if (isMe) { + isTeleported + } else { + person.id.lowercase(Locale.ROOT) in teleportedPersonIds + } GeohashPersonItem( person = person, - isMe = myHex != null && person.id == myHex, + isMe = isMe, hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"), - isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id), - isMyTeleported = person.id == myHex && isTeleported, - nickname = nickname, - colorScheme = colorScheme, + isTeleported = personIsTeleported, viewModel = viewModel, showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1, onTap = { - if (person.id != myHex) { + if (!isMe) { viewModel.startGeohashDM(person.id) onTapPerson() } @@ -162,65 +149,71 @@ fun GeohashPeopleList( ) } - if (namedPeople.isNotEmpty()) { - SheetIconSectionHeader( - iconRes = R.drawable.ic_spec_people, - title = stringResource(R.string.section_people) + if (sections.onLocation.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_on_location)) + PeopleCard( + people = sections.onLocation, + row = { personRow(it) } ) - PeopleCard(people = namedPeople, row = { personRow(it) }) } - if (anonPeople.isNotEmpty()) { - SheetIconSectionHeader( - iconRes = R.drawable.ic_spec_teleport, - title = stringResource(R.string.section_anon), - modifier = Modifier.padding(top = if (namedPeople.isNotEmpty()) 20.dp else 0.dp) + if (sections.teleportedIn.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_teleported_in)) + PeopleCard( + people = sections.teleportedIn, + row = { personRow(it) } ) - PeopleCard(people = anonPeople, capped = true, row = { personRow(it) }) } } } } -/** Anonymous participants beyond this many are hidden behind the "n more" affordance. */ -internal const val MaxVisibleAnons = 5 +internal data class GeohashPeopleSections( + val onLocation: List, + val teleportedIn: List +) /** - * Whether this participant never set a nickname. - * - * The app labels them `anon` or `anon1234` before the `#abcd` disambiguator, so the base name is - * what identifies them. + * Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities + * are omitted, while announced names such as `anon1234` remain ordinary participants. Self is + * retained even before a nickname announcement and is always first in the matching section. */ -internal fun GeoPerson.isAnonymous(): Boolean { - val base = splitSuffix(displayName).first - return base == "anon" || (base.startsWith("anon") && base.drop(4).all { it.isDigit() }) +internal fun sectionGeohashPeople( + people: List, + myId: String?, + selfIsTeleported: Boolean, + teleportedIds: Set +): GeohashPeopleSections { + val normalizedMyId = myId?.lowercase(Locale.ROOT) + val normalizedTeleportedIds = teleportedIds + .mapTo(mutableSetOf()) { it.lowercase(Locale.ROOT) } + fun isSelf(person: GeoPerson): Boolean = + normalizedMyId != null && person.id.lowercase(Locale.ROOT) == normalizedMyId + fun isTeleported(person: GeoPerson): Boolean = + if (isSelf(person)) selfIsTeleported + else person.id.lowercase(Locale.ROOT) in normalizedTeleportedIds + + val displayedPeople = people.filter { person -> + isSelf(person) || !isUnannouncedNickname(person.displayName) + } + val ordered = displayedPeople.sortedWith( + compareByDescending(::isSelf) + .thenByDescending { it.lastSeen } + ) + return GeohashPeopleSections( + onLocation = ordered.filterNot(::isTeleported), + teleportedIn = ordered.filter(::isTeleported) + ) } -/** - * One grouped card of people. - * - * When [capped] the list is trimmed to [MaxVisibleAnons] rows and the remainder is collapsed behind - * a count. That matters for the anonymous section: a popular geohash can hold dozens of anons, which - * pushed everyone worth recognising off screen and turned the sheet into a wall of near-identical - * rows. - * - * The capped card is a **fixed height** — [MaxVisibleAnons] rows plus the overflow line, always, - * regardless of how many anons are currently present beyond the cap. Anons join and leave a busy - * geohash constantly, and sizing to the live count made the card grow and shrink under the reader - * every few seconds. - */ +/** One uncapped card of people. The enclosing sheet owns scrolling. */ @Composable private fun PeopleCard( people: List, - row: @Composable (GeoPerson) -> Unit, - capped: Boolean = false + row: @Composable (GeoPerson) -> Unit ) { val palette = LocalBitchatPalette.current - val visible = if (capped) people.take(MaxVisibleAnons) else people - val hiddenCount = people.size - visible.size - val isTrimmed = capped && people.size > MaxVisibleAnons - Surface( modifier = Modifier .fillMaxWidth() @@ -229,59 +222,10 @@ private fun PeopleCard( color = palette.surface, shape = AboutCardShape ) { - Column { - Box( - // Reserve the full capped height up front so the card cannot resize as anons - // churn. Rows are a fixed height, so this is exact rather than an estimate. - modifier = if (isTrimmed) { - Modifier.height(SheetRowHeight * MaxVisibleAnons) - } else { - Modifier - } - ) { - AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> - Column { - if (index > 0) SheetCardDivider() - if (isTrimmed && index == visible.lastIndex) { - // Fade only the final row, so the gradient reads as "the list - // continues" rather than dimming content still meant to be read. - Box { - row(person) - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - listOf( - palette.surface.copy(alpha = 0f), - palette.surface.copy(alpha = 0.85f) - ) - ) - ) - ) - } - } else { - row(person) - } - } - } - } - - if (isTrimmed) { - // Always laid out when trimmed, so the count changing never moves anything. - // Only the number itself animates. - AnimatedCountLabel( - count = hiddenCount, - text = stringResource(R.string.people_n_more, hiddenCount), - fontFamily = BitchatFontFamily, - fontSize = 11.sp, - color = palette.textTertiary, - modifier = Modifier.padding( - start = SheetRowHorizontal, - end = SheetRowHorizontal, - bottom = SheetRowVertical - ) - ) + AnimatedRowColumn(items = people, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + row(person) } } } @@ -293,25 +237,15 @@ private fun GeohashPersonItem( isMe: Boolean, hasUnreadDM: Boolean, isTeleported: Boolean, - isMyTeleported: Boolean, - nickname: String, - colorScheme: ColorScheme, viewModel: ChatViewModel, showHashSuffix: Boolean, onTap: () -> Unit ) { val palette = LocalBitchatPalette.current - val (iconName, iconColor) = when { - 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 - } - val statusIconRes = when (iconName) { - "face.dashed" -> R.drawable.ic_spec_teleport - else -> R.drawable.ic_spec_person - } + val statusIconRes = + if (isTeleported) R.drawable.ic_spec_teleport + else R.drawable.ic_spec_on_location_person val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) val baseName = truncateNickname(baseNameRaw) @@ -343,9 +277,13 @@ private fun GeohashPersonItem( } else { Icon( painter = painterResource(statusIconRes), - contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User", + contentDescription = if (isTeleported) { + stringResource(R.string.cd_teleported) + } else { + stringResource(R.string.section_on_location) + }, modifier = Modifier.size(22.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) + tint = baseColor ) } } diff --git a/app/src/main/res/drawable/ic_spec_on_location_person.xml b/app/src/main/res/drawable/ic_spec_on_location_person.xml new file mode 100644 index 00000000..507d47d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_on_location_person.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 212f1be2..5ee5c4c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -317,8 +317,8 @@ People (%1$d) People - Anon - %1$d more\u2026 + On location + Teleported in Grant location permission Location permission denied. Enable in settings to use location channels. diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt deleted file mode 100644 index c4a76fcf..00000000 --- a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.bitchat.android.ui - -import java.util.Date -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * A busy geohash is mostly anonymous drive-by participants. If they sort by recency alongside - * everyone else they bury the handful of people worth recognising, so they are pushed to the end of - * the list and capped. - */ -class GeohashAnonOrderingTest { - - private fun person(name: String, secondsAgo: Long = 0) = GeoPerson( - id = "id-$name", - displayName = name, - lastSeen = Date(1_000_000L - secondsAgo * 1000L) - ) - - @Test - fun `bare anon is anonymous`() { - assertTrue(person("anon").isAnonymous()) - assertTrue(person("anon#04af").isAnonymous()) - } - - @Test - fun `numbered anon is anonymous`() { - assertTrue(person("anon7674").isAnonymous()) - assertTrue(person("anon7674#df5b").isAnonymous()) - } - - @Test - fun `a chosen nickname is not anonymous`() { - assertFalse(person("alice").isAnonymous()) - assertFalse(person("alice#1234").isAnonymous()) - } - - @Test - fun `a nickname that merely starts with anon is not anonymous`() { - // "anonymous" and "anonracer" are deliberate names, not the generated placeholder. - assertFalse(person("anonymous").isAnonymous()) - assertFalse(person("anonracer#04af").isAnonymous()) - } - - @Test - fun `named participants sort ahead of anons regardless of recency`() { - val me = person("me") - val people = listOf( - person("anon1", secondsAgo = 0), // most recent of all - person("zoe", secondsAgo = 500), // stalest named user - person("anon2", secondsAgo = 10), - person("alice", secondsAgo = 200) - ) - - val ordered = (people + me).sortedWith( - compareByDescending { it.id == me.id } - .thenBy { it.isAnonymous() } - .thenByDescending { it.lastSeen } - ) - - assertEquals( - listOf("me", "alice", "zoe", "anon1", "anon2"), - ordered.map { it.displayName } - ) - } - - @Test - fun `recency still orders within each group`() { - val people = listOf( - person("bob", secondsAgo = 100), - person("alice", secondsAgo = 10), - person("anon9", secondsAgo = 300), - person("anon1", secondsAgo = 5) - ) - - val ordered = people.sortedWith( - compareBy { it.isAnonymous() }.thenByDescending { it.lastSeen } - ) - - assertEquals( - listOf("alice", "bob", "anon1", "anon9"), - ordered.map { it.displayName } - ) - } - - @Test - fun `named participants are never trimmed`() { - val named = (1..20).map { person("user$it") } - assertEquals(20, named.filterNot { it.isAnonymous() }.size) - } - - @Test - fun `anons beyond the cap are counted as hidden`() { - val people = (1..12).map { person("anon$it") } + person("alice") - - val anons = people.filter { it.isAnonymous() } - val visible = anons.take(MaxVisibleAnons) - - assertEquals(MaxVisibleAnons, visible.size) - assertEquals(12 - MaxVisibleAnons, anons.size - visible.size) - } - - // MARK: - Sectioning - - /** - * Mirrors the two-group split in GeohashPeopleList: peers who announced a nickname, then the - * anons. Self is never treated as an anon. - */ - private fun sections(people: List, myId: String?): Triple, List, List> { - val isSelf: (GeoPerson) -> Boolean = { myId != null && it.id == myId } - val named = people.filter { isSelf(it) || !it.isAnonymous() } - val anons = people.filter { !isSelf(it) && it.isAnonymous() } - return Triple( - named.map { it.displayName }, - anons.map { it.displayName }, - people.map { it.displayName } - ) - } - - @Test - fun `anons are grouped out of the people section entirely`() { - val people = listOf(person("alice"), person("anon1"), person("bob"), person("anon2")) - val (named, anons, _) = sections(people, myId = null) - - assertEquals(listOf("alice", "bob"), named) - assertEquals(listOf("anon1", "anon2"), anons) - } - - @Test - fun `self stays in the people section even when unnamed`() { - // You always want to find yourself where you actually are, not buried in the anon section. - val me = person("anon") - val people = listOf(me, person("alice"), person("anon2")) - val (named, anons, _) = sections(people, myId = me.id) - - assertTrue("self must not be grouped as an anon", named.contains("anon")) - assertFalse(anons.contains("anon")) - assertEquals(listOf("anon2"), anons) - } - - @Test - fun `a list of only anons yields no people section`() { - val people = (1..4).map { person("anon$it") } - val (named, anons, _) = sections(people, myId = null) - - assertTrue(named.isEmpty()) - assertEquals(4, anons.size) - } - - // MARK: - Stable length - - @Test - fun `a trimmed anon list always renders exactly the cap`() { - // The reserved height is MaxVisibleAnons rows whenever trimmed, so the card cannot resize - // as anons churn above the cap. - for (total in listOf(MaxVisibleAnons + 1, MaxVisibleAnons + 7, MaxVisibleAnons + 40)) { - val anons = (1..total).map { person("anon$it") } - val visible = anons.take(MaxVisibleAnons) - assertEquals( - "row count must not depend on how many anons are present beyond the cap", - MaxVisibleAnons, - visible.size - ) - assertEquals(total - MaxVisibleAnons, anons.size - visible.size) - } - } - - @Test - fun `reordering never changes how many rows are rendered`() { - val anons = (1..9).map { person("anon$it", secondsAgo = it.toLong()) } - val byRecency = anons.sortedByDescending { it.lastSeen }.take(MaxVisibleAnons) - val reversed = anons.sortedBy { it.lastSeen }.take(MaxVisibleAnons) - - assertEquals(byRecency.size, reversed.size) - assertEquals(MaxVisibleAnons, byRecency.size) - } - - @Test - fun `a short anon list is shown in full with nothing hidden`() { - val people = listOf(person("alice"), person("anon1"), person("anon2")) - - val anons = people.filter { it.isAnonymous() } - assertEquals(2, anons.take(MaxVisibleAnons).size) - assertEquals(0, anons.size - anons.take(MaxVisibleAnons).size) - } -} diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt new file mode 100644 index 00000000..3c655f44 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt @@ -0,0 +1,83 @@ +package com.bitchat.android.ui + +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GeohashPresenceGroupingTest { + + private fun person(id: String, name: String = id, lastSeen: Long = 0) = GeoPerson( + id = id, + displayName = name, + lastSeen = Date(lastSeen) + ) + + @Test + fun `heartbeat anon is hidden while announced anon name remains`() { + val people = listOf( + person("local-heartbeat", "anon"), + person("named", "alice"), + person("teleported-named", "anon7674"), + person("teleported-heartbeat", "anon#04af") + ) + + val sections = sectionGeohashPeople( + people = people, + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("teleported-named", "teleported-heartbeat") + ) + + assertEquals(listOf("alice"), sections.onLocation.map { it.displayName }) + assertEquals(listOf("anon7674"), sections.teleportedIn.map { it.displayName }) + assertEquals(2, sections.onLocation.size + sections.teleportedIn.size) + } + + @Test + fun `self is first in on-location section when not teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("recent", lastSeen = 3_000), + person("me", name = "anon", lastSeen = 0), + person("older", lastSeen = 1_000) + ), + myId = "me", + selfIsTeleported = false, + teleportedIds = emptySet() + ) + + assertEquals(listOf("me", "recent", "older"), sections.onLocation.map { it.id }) + assertTrue(sections.teleportedIn.isEmpty()) + } + + @Test + fun `self is first in teleported section when teleported`() { + val sections = sectionGeohashPeople( + people = listOf( + person("remote-teleport", lastSeen = 3_000), + person("me", lastSeen = 0), + person("local", lastSeen = 2_000) + ), + myId = "ME", + selfIsTeleported = true, + teleportedIds = setOf("remote-teleport") + ) + + assertEquals(listOf("local"), sections.onLocation.map { it.id }) + assertEquals(listOf("me", "remote-teleport"), sections.teleportedIn.map { it.id }) + } + + @Test + fun `remote teleport matching is case insensitive`() { + val sections = sectionGeohashPeople( + people = listOf(person("ABCDEF")), + myId = null, + selfIsTeleported = false, + teleportedIds = setOf("abcdef") + ) + + assertTrue(sections.onLocation.isEmpty()) + assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id }) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt index 75fb6c01..607aaf1e 100644 --- a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt @@ -25,7 +25,7 @@ class MentionSuggestionsTest { listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"), suggestions ) - assertTrue(suggestions.none(::isUnannouncedMentionNickname)) + assertTrue(suggestions.none(::isUnannouncedNickname)) } @Test @@ -40,11 +40,11 @@ class MentionSuggestionsTest { @Test fun `announced names beginning with anon stay mentionable`() { - assertTrue(isUnannouncedMentionNickname("anon")) - assertTrue(isUnannouncedMentionNickname("anon#04af")) - assertFalse(isUnannouncedMentionNickname("anon1234#04af")) - assertFalse(isUnannouncedMentionNickname("anonymous#04af")) - assertFalse(isUnannouncedMentionNickname("anonracer")) + assertTrue(isUnannouncedNickname("anon")) + assertTrue(isUnannouncedNickname("anon#04af")) + assertFalse(isUnannouncedNickname("anon1234#04af")) + assertFalse(isUnannouncedNickname("anonymous#04af")) + assertFalse(isUnannouncedNickname("anonracer")) } @Test From 25e9779f7357edc7b2224fd92b4eafdbb3ed14d0 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:06:22 +0200 Subject: [PATCH 33/37] colors --- .../core/ui/component/button/CloseButton.kt | 6 +- .../com/bitchat/android/ui/AboutSections.kt | 36 +++--- .../java/com/bitchat/android/ui/AboutSheet.kt | 48 ++++---- .../java/com/bitchat/android/ui/ChatHeader.kt | 24 ++-- .../java/com/bitchat/android/ui/ChatScreen.kt | 17 +-- .../com/bitchat/android/ui/ChatUIUtils.kt | 105 +++++------------- .../com/bitchat/android/ui/ChatUserSheet.kt | 14 +-- .../com/bitchat/android/ui/ChatViewModel.kt | 11 +- .../bitchat/android/ui/GeohashPeopleList.kt | 13 ++- .../android/ui/GeohashPickerActivity.kt | 8 +- .../bitchat/android/ui/GeohashViewModel.kt | 6 +- .../com/bitchat/android/ui/InputComponents.kt | 35 +++--- .../com/bitchat/android/ui/LinkPreviewPill.kt | 11 +- .../android/ui/LocationChannelsSheet.kt | 90 +++++++++------ .../bitchat/android/ui/LocationNotesButton.kt | 4 +- .../bitchat/android/ui/LocationNotesSheet.kt | 12 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 25 +++-- .../bitchat/android/ui/MessageComponents.kt | 31 ++++-- .../com/bitchat/android/ui/PeerColorSeed.kt | 33 ++++++ .../android/ui/SecurityVerificationSheet.kt | 7 +- .../bitchat/android/ui/VerificationSheet.kt | 4 +- .../android/ui/media/AudioMessageItem.kt | 3 +- .../android/ui/media/ImageMessageItem.kt | 3 +- .../android/ui/theme/BitchatPalette.kt | 81 +++----------- .../bitchat/android/ui/theme/PeerColors.kt | 30 +++++ .../com/bitchat/android/ui/theme/Theme.kt | 67 ++++++----- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 72 +++++++++--- 27 files changed, 415 insertions(+), 381 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt index 091f93fc..9c60123f 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -6,26 +6,26 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitchat.android.R -import com.bitchat.android.ui.theme.LocalBitchatPalette @Composable fun CloseButton( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme IconButton( onClick = onClick, // 44.dp to match every other tap target in the app's chrome. modifier = modifier.size(44.dp), colors = IconButtonDefaults.iconButtonColors( - contentColor = palette.accentGreen, + contentColor = colorScheme.primary, containerColor = Color.Transparent ) ) { diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt index e6da1eee..e36ee7be 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -120,7 +120,6 @@ internal fun SheetIconSectionHeader( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Column( modifier = modifier @@ -152,7 +151,7 @@ internal fun SheetIconSectionHeader( fontSize = 12.sp, lineHeight = 17.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -161,11 +160,11 @@ internal fun SheetIconSectionHeader( /** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ @Composable internal fun SheetCardDivider() { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme HorizontalDivider( modifier = Modifier.padding(start = SheetRowDividerInset), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) } @@ -212,7 +211,7 @@ internal fun AboutHero( text = stringResource(R.string.about_tagline), fontFamily = BitchatFontFamily, fontSize = 16.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(6.dp)) @@ -238,7 +237,6 @@ internal fun AboutTabBar( onSelect: (AboutTab) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme val density = LocalDensity.current @@ -274,7 +272,7 @@ internal fun AboutTabBar( } Box(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) Box( modifier = Modifier // Lambda overload: the offset is animated every frame, and the non-lambda @@ -295,11 +293,10 @@ private fun AboutTabLabel( 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, + targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing), label = "aboutTabLabelColor" ) @@ -328,7 +325,6 @@ private fun AboutInstructionRow( text: String ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Row( modifier = Modifier @@ -350,7 +346,7 @@ private fun AboutInstructionRow( fontFamily = BitchatFontFamily, fontSize = 14.sp, lineHeight = 20.sp, - color = palette.textPrimary + color = colorScheme.onSurface ) } } @@ -453,7 +449,6 @@ private fun AboutFeatureRow( subtitle: String ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Row( modifier = Modifier @@ -477,14 +472,14 @@ private fun AboutFeatureRow( fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp, - color = palette.textPrimary + color = colorScheme.onSurface ) Text( text = subtitle, fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -498,10 +493,10 @@ internal fun BitchatBadge( text: String, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Box( modifier = modifier - .background(palette.accentGreen.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) .padding(horizontal = 5.dp, vertical = 2.dp) ) { Text( @@ -510,7 +505,7 @@ internal fun BitchatBadge( fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, - color = palette.accentGreen + color = colorScheme.primary ) } } @@ -556,11 +551,10 @@ internal fun SheetHeaderBadge( 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), + .background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape), contentAlignment = Alignment.Center ) { Icon( @@ -585,8 +579,8 @@ internal fun SheetDestructiveButton( modifier: Modifier = Modifier, isDestructive: Boolean = true ) { - val palette = LocalBitchatPalette.current - val accent = if (isDestructive) palette.accentRed else palette.accentGreen + val colorScheme = MaterialTheme.colorScheme + val accent = if (isDestructive) colorScheme.error else colorScheme.primary Surface( onClick = onClick, diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 907f7673..67065182 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -82,17 +82,17 @@ private fun ThemeChip( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // 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, + targetValue = if (selected) colorScheme.primary else colorScheme.surfaceVariant, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "themeChipContainer" ) val labelColor by animateColorAsState( - targetValue = if (selected) Color.White else palette.textSecondary, + targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "themeChipLabel" ) @@ -146,12 +146,12 @@ private fun SettingsToggleRow( label = "settingsRowIcon" ) val titleColor by animateColorAsState( - targetValue = if (enabled) palette.textPrimary else palette.textTertiary, + targetValue = if (enabled) colorScheme.onSurface else palette.textTertiary, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "settingsRowTitle" ) val subtitleColor by animateColorAsState( - targetValue = if (enabled) palette.textSecondary else palette.textTertiary, + targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "settingsRowSubtitle" ) @@ -213,9 +213,9 @@ private fun SettingsToggleRow( interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = palette.accentGreen, + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, - uncheckedTrackColor = palette.surfaceVariant + uncheckedTrackColor = colorScheme.surfaceVariant ) ) } @@ -310,7 +310,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Row( @@ -359,7 +359,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -383,7 +383,7 @@ fun AboutSheet( HorizontalDivider( modifier = Modifier.padding(start = 54.dp), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) // Proof of Work Toggle @@ -398,7 +398,7 @@ fun AboutSheet( HorizontalDivider( modifier = Modifier.padding(start = 54.dp), thickness = 1.dp, - color = palette.outlineVariant + color = colorScheme.outlineVariant ) // Tor Toggle @@ -417,9 +417,9 @@ fun AboutSheet( statusIndicator = if (torMode.value == TorMode.ON) { { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary torStatus.running -> palette.accentOrange - else -> palette.accentRed + else -> colorScheme.error } Surface( color = statusColor, @@ -811,7 +811,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column( @@ -828,7 +828,7 @@ fun AboutSheet( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = palette.textPrimary + color = colorScheme.onSurface ) AnimatedCountLabel( count = powDifficulty, @@ -839,7 +839,7 @@ fun AboutSheet( ), fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } @@ -849,9 +849,9 @@ fun AboutSheet( valueRange = 0f..32f, steps = 31, colors = SliderDefaults.colors( - thumbColor = palette.accentGreen, - activeTrackColor = palette.accentGreen, - inactiveTrackColor = palette.surfaceVariant + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.surfaceVariant ) ) @@ -888,7 +888,7 @@ fun AboutSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column( @@ -900,9 +900,9 @@ fun AboutSheet( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val statusColor = when { - torStatus.running && torStatus.bootstrapPercent >= 100 -> palette.accentGreen + torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary torStatus.running -> palette.accentOrange - else -> palette.accentRed + else -> colorScheme.error } Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} Text( @@ -914,7 +914,7 @@ fun AboutSheet( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = palette.textPrimary + color = colorScheme.onSurface ) } if (torStatus.lastLogLine.isNotEmpty()) { @@ -974,7 +974,7 @@ fun AboutSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(64.dp) - .background(palette.background.copy(alpha = topBarAlpha)) + .background(colorScheme.background.copy(alpha = topBarAlpha)) ) { val dismiss = LocalSheetDismiss.current CloseButton( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index f1b2fbe2..8993b3ea 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -119,11 +119,12 @@ internal data class TorConnectionVisual( @Composable internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState() // ~28% of the loud accent mixed into the base tint keeps the hue without intensity. val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f) - val mutedFailed = lerp(normal, palette.accentRed, 0.30f) + val mutedFailed = lerp(normal, colorScheme.error, 0.30f) val target = when { torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false) @@ -268,18 +269,19 @@ fun NoiseSessionIcon( modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with // an all-but-transparent RGB - the icons were effectively invisible. They now use the // palette's secondary text colour. val (icon, color, contentDescription) = when (sessionState) { "uninitialized" -> Triple( Icons.Outlined.NoEncryption, - palette.textSecondary, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_ready_for_handshake) ) "handshaking" -> Triple( Icons.Outlined.Sync, - palette.textSecondary, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_handshake_in_progress) ) "established" -> Triple( @@ -290,7 +292,7 @@ fun NoiseSessionIcon( else -> { // "failed" or any other state Triple( Icons.Outlined.Warning, - palette.accentRed, + colorScheme.error, stringResource(R.string.cd_handshake_failed) ) } @@ -365,19 +367,20 @@ fun PeerCounter( modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // 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 - Pair(count, if (count > 0) palette.accentGreen else palette.textTertiary) + Pair(count, if (count > 0) colorScheme.primary else palette.textTertiary) } is com.bitchat.android.geohash.ChannelID.Mesh, null -> { // Mesh channel: show Bluetooth-connected peers (excluding self) val count = connectedPeers.size - Pair(count, if (isConnected && count > 0) palette.accentBlue else palette.textTertiary) + Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary) } } @@ -424,7 +427,7 @@ fun PeerCounter( prefix = stringResource(R.string.channel_count_prefix), style = MaterialTheme.typography.bodyMedium, fontSize = HeaderTextSize, - color = if (isConnected) palette.accentGreen else palette.accentRed, + color = if (isConnected) colorScheme.primary else colorScheme.error, fontWeight = FontWeight.Medium ) } @@ -482,7 +485,6 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current Box(modifier = Modifier.fillMaxWidth()) { // Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that @@ -520,7 +522,7 @@ private fun ChannelHeader( text = stringResource(R.string.chat_leave), style = MaterialTheme.typography.labelMedium, fontSize = 15.sp, - color = palette.accentRed, + color = colorScheme.error, modifier = Modifier .align(Alignment.CenterEnd) .clip(HeaderClusterShape) @@ -667,7 +669,7 @@ private fun LocationChannelsButton( viewModel: ChatViewModel, onClick: () -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme // Get current channel selection from location manager val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -680,7 +682,7 @@ private fun LocationChannelsButton( // so it is plain "mesh". else -> stringResource(R.string.mesh_label) } - val channelColor = if (isLocation) palette.accentGreen else palette.accentBlue + val channelColor = if (isLocation) colorScheme.primary else colorScheme.secondary // Tor status only tints the globe (location channels). The local mesh stays blue. val torVisual = if (isLocation) { rememberTorConnectionVisual(normal = channelColor) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index ec3c5d4f..60fd7d4a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -46,7 +46,6 @@ import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.NearbyNotesController 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 @@ -61,7 +60,6 @@ import com.bitchat.android.ui.theme.LocalBitchatPalette @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() @@ -415,17 +413,17 @@ fun ChatScreen(viewModel: ChatViewModel) { ) { Surface( shape = CircleShape, - color = palette.surface, + color = colorScheme.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(1.dp, palette.accentGreen) + border = BorderStroke(1.dp, colorScheme.primary) ) { IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) { Icon( imageVector = Icons.Filled.ArrowDownward, contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom), modifier = Modifier.size(22.dp), - tint = palette.accentGreen + tint = colorScheme.primary ) } } @@ -569,8 +567,6 @@ fun ChatInputSection( showMediaButtons: Boolean, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current - Column( // Flat, slightly translucent screen background — the same treatment as the top bar, so the // two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked @@ -581,7 +577,7 @@ fun ChatInputSection( .background(colorScheme.background.copy(alpha = BarBackgroundAlpha)) ) { // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Command suggestions box if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { @@ -590,7 +586,7 @@ fun ChatInputSection( onSuggestionClick = onCommandSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) } // Retain the final populated list while the picker exits. The state layer clears // suggestions together with visibility; without this snapshot the panel would empty and @@ -631,7 +627,7 @@ fun ChatInputSection( onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) } } MessageInput( @@ -682,7 +678,6 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - val palette = LocalBitchatPalette.current Box( modifier = Modifier diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 05425f60..14676d07 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -10,6 +10,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatPalette import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.colorForPeerSeed import java.text.SimpleDateFormat import java.util.* @@ -57,7 +58,11 @@ fun formatTextMessageSender( ): AnnotatedString { val builder = AnnotatedString.Builder() val isSelf = message.isFromSelf(currentUserNickname, myPeerID) - val senderColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val senderColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeerSeed(peerColorSeedForMessage(message), palette) + } val senderWeight = FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) @@ -161,12 +166,12 @@ private fun appendBodyTimestamp( private fun appendMutedTimestamp( builder: AnnotatedString.Builder, message: BitchatMessage, - palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat, ) { builder.pushStyle( SpanStyle( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), fontSize = ChatVisualTokens.SystemTimeFontSize, fontWeight = FontWeight.Normal, ) @@ -186,6 +191,8 @@ fun formatTextMessageBody( message: BitchatMessage, currentUserNickname: String, palette: BitchatPalette, + contentColor: Color, + linkColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeTimestamp: Boolean = true ): AnnotatedString { @@ -196,6 +203,8 @@ fun formatTextMessageBody( content = message.content, currentUserNickname = currentUserNickname, palette = palette, + contentColor = contentColor, + linkColor = linkColor, ) if (includeTimestamp) { @@ -212,13 +221,13 @@ fun formatTextMessageBody( */ fun formatSystemMessage( message: BitchatMessage, - palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) ): AnnotatedString { val builder = AnnotatedString.Builder() builder.pushStyle( SpanStyle( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), fontSize = ChatVisualTokens.SystemActionFontSize, fontWeight = FontWeight.Medium, ) @@ -227,7 +236,7 @@ fun formatSystemMessage( builder.append(message.content) builder.pop() - appendMutedTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, contentColor, timeFormatter) return builder.toAnnotatedString() } @@ -243,6 +252,7 @@ fun formatMessageHeaderAnnotatedString( currentUserNickname: String, myPeerID: String, palette: BitchatPalette, + contentColor: Color, timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeSender: Boolean = true ): AnnotatedString { @@ -250,11 +260,15 @@ fun formatMessageHeaderAnnotatedString( val isSelf = message.isFromSelf(currentUserNickname, myPeerID) if (message.sender == "system") { - return formatSystemMessage(message, palette, timeFormatter) + return formatSystemMessage(message, contentColor, timeFormatter) } if (includeSender) { - val baseColor = if (isSelf) palette.accentOrange else getPeerColor(message, palette.isDark) + val baseColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeerSeed(peerColorSeedForMessage(message), palette) + } val (baseName, suffix) = splitSuffix(message.sender) builder.pushStyle( @@ -291,71 +305,10 @@ fun formatMessageHeaderAnnotatedString( } } - appendMutedTimestamp(builder, message, palette, timeFormatter) + appendMutedTimestamp(builder, message, contentColor, timeFormatter) return builder.toAnnotatedString() } -/** - * iOS-style peer color assignment using djb2 hash algorithm - * Avoids orange (~30°) reserved for self messages - */ -fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color { - // Create seed from peer identifier (prioritizing stable keys) - val seed = when { - message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> { - // For Nostr peers, use the full key if available, otherwise the peer ID - "nostr:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 16 -> { - // For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID - "noise:${message.senderPeerID.lowercase()}" - } - message.senderPeerID?.length == 64 -> { - // This is already a stable Noise key - "noise:${message.senderPeerID.lowercase()}" - } - else -> { - // Fallback to sender name - message.sender.lowercase() - } - } - - return colorForPeerSeed(seed, isDark) -} - -/** - * 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) - var hash = 5381UL - for (byte in seed.toByteArray()) { - hash = ((hash shl 5) + hash) + byte.toUByte().toULong() - } - - var hue = (hash % 360UL).toDouble() / 360.0 - - // Avoid orange (~30°) reserved for self (matches iOS logic) - val orange = 30.0 / 360.0 - if (kotlin.math.abs(hue - orange) < 0.05) { - hue = (hue + 0.12) % 1.0 - } - - val saturation = if (isDark) 1.0 else 0.85 - val brightness = if (isDark) 1.0 else 0.45 - - return Color.hsv( - hue = (hue * 360).toFloat(), - saturation = saturation.toFloat(), - value = brightness.toFloat() - ) -} - /** * Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly) */ @@ -387,7 +340,7 @@ internal fun isUnannouncedNickname(displayName: String): Boolean { * iOS-style content formatting with proper hashtag and mention handling. * * Redesign notes: - * - Plain text renders in [BitchatPalette.textPrimary]; colour is reserved for `@mentions`, + * - Plain text renders in Material `onSurface`; 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 @@ -401,10 +354,9 @@ private fun appendIOSFormattedContent( content: String, currentUserNickname: String, palette: BitchatPalette, + contentColor: Color, + linkColor: Color, ) { - 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() @@ -511,7 +463,10 @@ private fun appendIOSFormattedContent( palette.accentOrange } else { // Tint by the *mentioned* peer so a given name looks identical everywhere. - colorForPeerSeed(mentionWithoutAt.lowercase(), palette.isDark) + colorForPeerSeed( + PeerColorSeed(mentionWithoutAt.lowercase(Locale.ROOT)), + palette + ) } val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index b6bf7606..f0647e6c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import com.bitchat.android.ui.theme.LocalBitchatPalette import androidx.compose.ui.res.stringResource import com.bitchat.android.R import androidx.compose.ui.platform.LocalClipboardManager @@ -35,14 +36,13 @@ fun ChatUserSheet( val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current - // iOS system colors (matches LocationChannelsSheet 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 standardPurple = if (isDark) Color(0xFFBF5AF2) else Color(0xFFAF52DE) // iOS purple - val standardRed = Color(0xFFFF3B30) // iOS red - val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey + val palette = LocalBitchatPalette.current + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary + val standardPurple = palette.accentPurple + val standardRed = colorScheme.error + val standardGrey = colorScheme.onSurfaceVariant if (isPresented) { BitchatBottomSheet( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4eb32739..4c6d5cdc 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1195,17 +1195,12 @@ class ChatViewModel( /** * Get consistent color for a mesh peer by ID (iOS-compatible) */ - fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - // Try to get stable Noise key, fallback to peer ID - val seed = "noise:${peerID.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } + fun peerColorSeedForMeshPeer(peerID: String): PeerColorSeed = meshPeerColorSeed(peerID) /** * Get consistent color for a Nostr pubkey (iOS-compatible) */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) -} + fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = + geohashViewModel.peerColorSeedForNostrPubkey(pubkeyHex) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 7610fedf..8f65bbb1 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BitchatFontFamily +import com.bitchat.android.ui.theme.colorForPeerSeed import com.bitchat.android.R import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -45,6 +46,7 @@ fun GeohashPeopleList( val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val myHex = remember(selectedLocationChannel) { when (val channel = selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { @@ -110,7 +112,7 @@ fun GeohashPeopleList( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Text( @@ -212,14 +214,14 @@ private fun PeopleCard( people: List, row: @Composable (GeoPerson) -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { AnimatedRowColumn(items = people, key = { it.id }) { index, person -> @@ -250,7 +252,10 @@ private fun GeohashPersonItem( val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) val baseName = truncateNickname(baseNameRaw) val suffix = if (showHashSuffix) suffixRaw else "" - val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark) + val assignedColor = colorForPeerSeed( + viewModel.peerColorSeedForNostrPubkey(person.id), + palette + ) val baseColor = if (isMe) palette.accentOrange else assignedColor Row( diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index bece684a..9565c384 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -30,10 +30,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.bitchat.android.ui.theme.BitchatTheme import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.res.stringResource @@ -87,15 +87,13 @@ class GeohashPickerActivity : OrientationAwareActivity() { val initialPrecision = geohashToFocus?.length ?: 5 setContent { - MaterialTheme { + BitchatTheme { var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") } var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) } var webViewRef by remember { mutableStateOf(null) } - // iOS system-like colors used across app 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) + val standardGreen = colorScheme.primary Scaffold { padding -> Box(Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 4cae30dd..d94e5fff 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -332,10 +332,8 @@ class GeohashViewModel( fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - val seed = "nostr:${pubkeyHex.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } + fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = + nostrPeerColorSeed(pubkeyHex) private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 07f633dc..a732b8e8 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -257,7 +257,8 @@ internal fun ComposerActionSurface( content: @Composable (tint: Color) -> Unit ) { val palette = LocalBitchatPalette.current - val accent = if (activeColor == Color.Unspecified) palette.accentGreen else activeColor + val colorScheme = MaterialTheme.colorScheme + val accent = if (activeColor == Color.Unspecified) colorScheme.primary else activeColor val container by animateColorAsState( // A tint rather than a fill. A solid accent disc next to the text you are typing was the @@ -267,7 +268,7 @@ internal fun ComposerActionSurface( label = "composerButtonContainer" ) val tint by animateColorAsState( - targetValue = if (isActive) accent else palette.textSecondary, + targetValue = if (isActive) accent else colorScheme.onSurfaceVariant, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "composerButtonTint" ) @@ -314,6 +315,7 @@ fun MessageInput( modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } val hasText = value.text.isNotBlank() val focusRequester = remember { FocusRequester() } @@ -324,7 +326,7 @@ fun MessageInput( // Recording is the one state worth shouting about, so it overrides focus. val borderColor by animateColorAsState( targetValue = when { - isRecording -> palette.accentRed.copy(alpha = 0.7f) + isRecording -> colorScheme.error.copy(alpha = 0.7f) isFocused.value -> palette.inputOutlineFocused else -> palette.inputOutline }, @@ -371,11 +373,11 @@ fun MessageInput( // user is composing rather than reading, and green-on-black is tiring to // type into. textStyle = MaterialTheme.typography.bodyMedium.copy( - color = palette.textPrimary, + color = colorScheme.onSurface, fontFamily = BitchatFontFamily ), cursorBrush = SolidColor( - if (isRecording) Color.Transparent else palette.textPrimary + if (isRecording) Color.Transparent else colorScheme.onSurface ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), keyboardActions = KeyboardActions(onSend = { @@ -387,8 +389,8 @@ fun MessageInput( CombinedVisualTransformation( listOf( SlashCommandVisualTransformation( - commandColor = palette.accentGreen, - commandBackground = palette.accentGreen.copy(alpha = 0.14f), + commandColor = colorScheme.primary, + commandBackground = colorScheme.primary.copy(alpha = 0.14f), ), MentionVisualTransformation( mentionColor = palette.accentOrange, @@ -452,7 +454,7 @@ fun MessageInput( secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 ), fontFamily = BitchatFontFamily, - color = palette.accentRed, + color = colorScheme.error, fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -595,6 +597,7 @@ private fun SendButton( enabled: Boolean = true ) { val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -602,7 +605,7 @@ private fun SendButton( isActive = enabled, isPressed = isPressed, // Private chats and channels keep their orange identity, disc and glyph together. - activeColor = if (isAccented) palette.accentOrange else palette.accentGreen, + activeColor = if (isAccented) palette.accentOrange else colorScheme.primary, modifier = modifier.clickable( interactionSource = interactionSource, indication = null, @@ -624,13 +627,13 @@ fun CommandSuggestionsBox( onSuggestionClick: (CommandSuggestion) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Column( modifier = modifier .verticalScroll(rememberScrollState()) - .background(palette.surface) - .border(1.dp, palette.outlineVariant, RoundedCornerShape(8.dp)) + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp)) .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> @@ -683,7 +686,7 @@ fun CommandSuggestionItem( style = MaterialTheme.typography.bodySmall.copy( fontFamily = BitchatFontFamily ), - color = palette.textSecondary, + color = colorScheme.onSurfaceVariant, fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -709,7 +712,7 @@ fun MentionSuggestionsBox( onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme LazyColumn( modifier = modifier @@ -721,8 +724,8 @@ fun MentionSuggestionsBox( ) ) .clip(MentionSuggestionsShape) - .background(palette.surface) - .border(1.dp, palette.outlineVariant, MentionSuggestionsShape), + .background(colorScheme.surface) + .border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape), contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { items( diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 9b34285c..57710171 100644 --- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -43,11 +43,8 @@ fun LinkPreviewPill( ) { val context = LocalContext.current val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - - // iOS-style colors - val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f) - val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f) + val textColor = colorScheme.secondary + val backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.35f) val borderColor = textColor.copy(alpha = 0.3f) // Parse URL for host extraction @@ -87,7 +84,7 @@ fun LinkPreviewPill( Surface( modifier = Modifier.size(60.dp), shape = RoundedCornerShape(8.dp), - color = Color.Blue.copy(alpha = 0.1f) + color = colorScheme.secondaryContainer ) { Box( modifier = Modifier.fillMaxSize(), @@ -97,7 +94,7 @@ fun LinkPreviewPill( imageVector = Icons.Outlined.Link, contentDescription = stringResource(com.bitchat.android.R.string.cd_link), modifier = Modifier.size(24.dp), - tint = Color.Blue + tint = colorScheme.secondary ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 7581dd46..ca5fd26e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -40,6 +42,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -71,7 +74,9 @@ import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text. @@ -152,8 +157,8 @@ fun LocationChannelsSheet( val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current - val standardGreen = palette.accentGreen - val standardBlue = palette.accentBlue + val standardGreen = colorScheme.primary + val standardBlue = colorScheme.secondary val nearbyChannels = remember(availableChannels) { availableChannels.filter { it.level != GeohashChannelLevel.BUILDING } @@ -185,7 +190,12 @@ fun LocationChannelsSheet( Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), + // Edge-to-edge + adjustResize reports the keyboard as WindowInsets.ime — + // without consuming it here the list stays full-height and the teleport + // field can't scroll above the keyboard. + modifier = Modifier + .fillMaxSize() + .imePadding(), contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(0.dp) ) { @@ -203,7 +213,7 @@ fun LocationChannelsSheet( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelOptionRow( @@ -253,7 +263,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelOptionRow( @@ -292,7 +302,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -353,7 +363,7 @@ fun LocationChannelsSheet( text = stringResource(R.string.location_permission_denied), fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.accentRed + color = colorScheme.error ) TextButton( onClick = { @@ -389,7 +399,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -443,12 +453,7 @@ fun LocationChannelsSheet( customError = null }, onFocusGained = { - coroutineScope.launch { - sheetState.expand() - listState.animateScrollToItem( - index = listState.layoutInfo.totalItemsCount - 1 - ) - } + coroutineScope.launch { sheetState.expand() } }, onOpenMap = { val normalized = customGeohash.trim().lowercase().replace("#", "") @@ -497,7 +502,7 @@ fun LocationChannelsSheet( text = shownError, fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.accentRed, + color = colorScheme.error, modifier = Modifier.padding( start = AboutHorizontalPadding + ChannelRowHorizontal, top = 8.dp @@ -518,7 +523,7 @@ fun LocationChannelsSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { ChannelSettingsToggleRow( @@ -638,7 +643,6 @@ private fun ChannelOptionRow( onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current val (baseTitle, countSuffix) = splitTitleAndCount(title) Row( @@ -657,7 +661,7 @@ private fun ChannelOptionRow( Box( modifier = Modifier .size(ChannelSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } leadingIcon != null -> { @@ -686,7 +690,7 @@ private fun ChannelOptionRow( fontSize = 14.sp, fontFamily = BitchatFontFamily, fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, - color = titleColor ?: palette.textPrimary + color = titleColor ?: colorScheme.onSurface ) countSuffix?.let { count -> AnimatedCountLabel( @@ -694,7 +698,7 @@ private fun ChannelOptionRow( text = count, fontSize = 11.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -703,7 +707,7 @@ private fun ChannelOptionRow( fontSize = 12.sp, fontFamily = BitchatFontFamily, lineHeight = 17.sp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } @@ -719,7 +723,7 @@ private fun ChannelBookmarkButton( bookmarked: Boolean, onClick: () -> Unit ) { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Box( modifier = Modifier .size(36.dp) @@ -737,7 +741,7 @@ private fun ChannelBookmarkButton( contentDescription = stringResource( if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark ), - tint = if (bookmarked) palette.accentGreen else palette.textSecondary, + tint = if (bookmarked) colorScheme.primary else colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -745,7 +749,7 @@ private fun ChannelBookmarkButton( @Composable private fun ChannelLoadingRow() { - val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme Row( modifier = Modifier .fillMaxWidth() @@ -760,14 +764,14 @@ private fun ChannelLoadingRow() { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } Text( text = stringResource(R.string.finding_nearby_channels), fontSize = 12.sp, fontFamily = BitchatFontFamily, - color = palette.textSecondary + color = colorScheme.onSurfaceVariant ) } } @@ -785,6 +789,10 @@ private fun CustomGeohashRow( ) { val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val coroutineScope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } val normalized = customGeohash.trim().lowercase().replace("#", "") val isValid = validateGeohash(normalized) // Typing the last character of a valid geohash arms the button; cross-fading both the label @@ -798,7 +806,7 @@ private fun CustomGeohashRow( targetValue = if (isValid) { colorScheme.primary.copy(alpha = 0.16f) } else { - palette.surfaceVariant + colorScheme.surfaceVariant }, animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), label = "teleportContainer" @@ -809,6 +817,7 @@ private fun CustomGeohashRow( Row( modifier = Modifier .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), verticalAlignment = Alignment.CenterVertically ) { @@ -847,7 +856,22 @@ private fun CustomGeohashRow( singleLine = true, modifier = Modifier .weight(1f) - .onFocusChanged { if (it.isFocused) onFocusGained() }, + .onFocusChanged { focusState -> + if (!focusState.isFocused) return@onFocusChanged + onFocusGained() + // Wait until IME insets have landed so the LazyColumn has shrunk; bringIntoView + // against the full-height viewport leaves the field tucked under the keyboard. + // Re-request once the IME animation settles — early insets are still growing. + coroutineScope.launch { + withTimeoutOrNull(750) { + snapshotFlow { imeInsets.getBottom(density) } + .first { it > 0 } + } + bringIntoViewRequester.bringIntoView() + delay(BitchatMotion.STANDARD_MS.toLong()) + bringIntoViewRequester.bringIntoView() + } + }, decorationBox = { inner -> if (customGeohash.isEmpty()) { Text( @@ -871,7 +895,7 @@ private fun CustomGeohashRow( Icon( imageVector = Icons.Filled.Map, contentDescription = stringResource(R.string.cd_open_map), - tint = palette.textSecondary, + tint = colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -937,7 +961,7 @@ private fun ChannelSettingsToggleRow( fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) palette.textPrimary else palette.textTertiary + color = if (enabled) colorScheme.onSurface else palette.textTertiary ) statusIndicator?.invoke() } @@ -946,7 +970,7 @@ private fun ChannelSettingsToggleRow( fontFamily = BitchatFontFamily, fontSize = 12.sp, lineHeight = 17.sp, - color = if (enabled) palette.textSecondary else palette.textTertiary + color = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary ) } Spacer(modifier = Modifier.width(16.dp)) @@ -956,9 +980,9 @@ private fun ChannelSettingsToggleRow( enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = palette.accentGreen, + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, - uncheckedTrackColor = palette.surfaceVariant + uncheckedTrackColor = colorScheme.surfaceVariant ) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 70f98d5a..489ddaf4 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -21,7 +21,6 @@ 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 for MainHeader. @@ -35,7 +34,6 @@ fun LocationNotesButton( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current val context = LocalContext.current val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() @@ -54,7 +52,7 @@ fun LocationNotesButton( if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) { val hasNotes = notesCount > 0 val contentDescription = stringResource(R.string.cd_location_notes) - val normalTint = if (hasNotes) colorScheme.primary else palette.textSecondary + val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant val torVisual = rememberTorConnectionVisual(normal = normalTint) Box( diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index c250f6a1..f3c49480 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -5,7 +5,6 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -51,10 +50,8 @@ fun LocationNotesSheet( modifier: Modifier = Modifier ) { val context = LocalContext.current - val isDark = isSystemInDarkTheme() - - // iOS color scheme - val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0) + val colorScheme = MaterialTheme.colorScheme + val accentGreen = colorScheme.primary // Managers val notesManager = remember { LocationNotesManager.getInstance() } @@ -468,7 +465,6 @@ private fun LocationNotesInputSection( nickname: String?, onSend: () -> Unit ) { - val isDark = isSystemInDarkTheme() val colorScheme = MaterialTheme.colorScheme Column( @@ -556,10 +552,8 @@ private fun LocationNotesInputSection( modifier = Modifier.size(20.dp), tint = if (!sendButtonEnabled) { colorScheme.onSurface.copy(alpha = 0.5f) - } else if (isDark) { - Color.Black // Black arrow on green in dark theme } else { - Color.White // White arrow on green in light theme + colorScheme.onPrimary } ) } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 9e1639b0..4bb36ecc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -47,6 +47,7 @@ import com.bitchat.android.identity.SecureIdentityStateManager 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.ui.theme.colorForPeerSeed import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -132,7 +133,7 @@ fun MeshPeerListSheet( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = LocalBitchatPalette.current.surface, + color = MaterialTheme.colorScheme.surface, shape = AboutCardShape ) { Column { @@ -222,7 +223,7 @@ fun MeshPeerListSheet( Icon( imageVector = Icons.Outlined.QrCode, contentDescription = stringResource(R.string.verify_title), - tint = LocalBitchatPalette.current.textSecondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp) ) } @@ -265,7 +266,7 @@ private fun ChannelRow( Box( modifier = Modifier .size(SheetRowSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } else if (unreadCount > 0) { UnreadBadge(count = unreadCount, colorScheme = colorScheme) @@ -286,7 +287,7 @@ private fun ChannelRow( text = channel, fontFamily = BitchatFontFamily, fontSize = 14.sp, - color = if (isSelected) colorScheme.primary else palette.textPrimary, + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, modifier = Modifier.weight(1f), maxLines = 1, @@ -331,7 +332,7 @@ fun PeopleSection( .fillMaxWidth() .padding(horizontal = AboutHorizontalPadding) .padding(top = 10.dp), - color = palette.surface, + color = colorScheme.surface, shape = AboutCardShape ) { Column { @@ -588,7 +589,10 @@ private fun PeerItem( // Get consistent peer color (iOS-compatible) val palette = LocalBitchatPalette.current - val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark) + val assignedColor = colorForPeerSeed( + viewModel.peerColorSeedForMeshPeer(peerID), + palette + ) val baseColor = if (isMe) palette.accentOrange else assignedColor Row( @@ -606,7 +610,7 @@ private fun PeerItem( Box( modifier = Modifier .size(SheetRowSelectedDot) - .background(palette.accentGreen, CircleShape) + .background(colorScheme.primary, CircleShape) ) } else if (hasUnreadDM) { Icon( @@ -642,7 +646,7 @@ private fun PeerItem( else -> "Routed" }, modifier = Modifier.size(PeerRowIconSize), - tint = palette.textSecondary + tint = colorScheme.onSurfaceVariant ) } } @@ -679,7 +683,7 @@ private fun PeerItem( imageVector = Icons.Filled.Verified, contentDescription = stringResource(R.string.verify_title), modifier = Modifier.size(16.dp), - tint = palette.accentGreen + tint = colorScheme.primary ) } } @@ -866,7 +870,6 @@ fun PrivateChatSheet( val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) - val palette = LocalBitchatPalette.current if (isPresented) { BitchatBottomSheet( @@ -879,7 +882,7 @@ fun PrivateChatSheet( ) { Spacer(modifier = Modifier.height(64.dp)) - HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index a0581a51..c8e05af0 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -498,6 +498,7 @@ fun MessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -516,7 +517,7 @@ fun MessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) // Try to load the file packet from the path @@ -564,14 +565,14 @@ fun MessageItem( .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) - .background(palette.surfaceVariant.copy(alpha = 0.85f), CircleShape) + .background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape) .clickable { onCancelTransfer?.invoke(message) }, contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), - tint = palette.textPrimary, + tint = colorScheme.onSurface, modifier = Modifier.size(14.dp) ) } @@ -591,10 +592,10 @@ fun MessageItem( if (message.sender == "system") { // Background narration: `// Tor started. Routing all chats…` - val annotatedText = remember(message, palette) { + val annotatedText = remember(message, colorScheme.onSurface) { formatSystemMessage( message = message, - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter ) } @@ -614,7 +615,7 @@ fun MessageItem( softWrap = true, overflow = TextOverflow.Visible, style = ChatVisualTokens.SystemActionStyle.copy( - color = palette.textPrimary.copy(alpha = ChatVisualTokens.MutedTextAlpha), + color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha), ) ) } else { @@ -660,11 +661,20 @@ internal fun TextMessageLayout( } // 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) { + val bodyText = remember( + displayMessage, + currentUserNickname, + palette, + colorScheme.onSurface, + colorScheme.secondary, + timeFormatter + ) { formatTextMessageBody( message = displayMessage, currentUserNickname = currentUserNickname, palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) } @@ -728,7 +738,7 @@ internal fun TextMessageLayout( fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = MessageBodyTextStyle.copy(color = palette.textPrimary), + style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), ) } } @@ -736,7 +746,6 @@ internal fun TextMessageLayout( @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme - val palette = LocalBitchatPalette.current // Status advances on its own as acks come back, so a hard glyph swap reads as a flicker. // Keyed on the status *type* rather than the instance, because Delivered/Read carry a @@ -758,9 +767,9 @@ fun DeliveryStatusIcon(status: DeliveryStatus) { DeliveryStatus.Delivered::class -> Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal) DeliveryStatus.Read::class -> - Triple(R.string.status_delivered, palette.accentBlue, FontWeight.Bold) + Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold) DeliveryStatus.Failed::class -> - Triple(R.string.status_failed, palette.accentRed, FontWeight.Normal) + Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal) // A single subdued check, without the numeric label. else -> Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) diff --git a/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt new file mode 100644 index 00000000..b6eac7cd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Stable, presentation-neutral identity used to derive a peer hue. + * + * ViewModels may expose this value, but only the UI theme resolves it to a rendered color. + */ +@JvmInline +value class PeerColorSeed(val value: String) + +fun meshPeerColorSeed(peerID: String): PeerColorSeed = + PeerColorSeed("noise:${peerID.lowercase(Locale.ROOT)}") + +fun nostrPeerColorSeed(pubkeyHex: String): PeerColorSeed = + PeerColorSeed("nostr:${pubkeyHex.lowercase(Locale.ROOT)}") + +fun peerColorSeedForMessage(message: BitchatMessage): PeerColorSeed { + val value = when { + message.senderPeerID?.startsWith("nostr:") == true || + message.senderPeerID?.startsWith("nostr_") == true -> { + "nostr:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + message.senderPeerID?.length == 16 || message.senderPeerID?.length == 64 -> { + "noise:${message.senderPeerID.lowercase(Locale.ROOT)}" + } + else -> message.sender.lowercase(Locale.ROOT) + } + + return PeerColorSeed(value) +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index d4ba21fe..40da30be 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.outlined.Warning as OutlinedWarning import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -70,9 +69,9 @@ fun SecurityVerificationSheet( val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() - val isDark = isSystemInDarkTheme() - val accent = if (isDark) Color.Green else Color(0xFF008000) - val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f) + val colorScheme = MaterialTheme.colorScheme + val accent = colorScheme.primary + val boxColor = colorScheme.surfaceVariant val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") } BitchatBottomSheet( diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index 4fc5bc34..a84c3c94 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -19,7 +19,6 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -99,8 +98,7 @@ fun VerificationSheet( ) { if (!isPresented) return - val isDark = isSystemInDarkTheme() - val accent = if (isDark) Color.Green else Color(0xFF008000) + val accent = MaterialTheme.colorScheme.primary var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan val nickname by viewModel.nickname.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 5627111d..c646d570 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -55,6 +55,7 @@ fun AudioMessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -73,7 +74,7 @@ fun AudioMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 30387944..5d9908fc 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -53,6 +53,7 @@ fun ImageMessageItem( currentUserNickname = currentUserNickname, myPeerID = meshService.myPeerID, palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, includeSender = showSender ) @@ -71,7 +72,7 @@ fun ImageMessageItem( }, onLongPress = { onMessageLongPress?.invoke(message) }, fontFamily = BitchatFontFamily, - color = palette.textPrimary, + color = colorScheme.onSurface, ) val context = LocalContext.current diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index dab45ff5..96ef57d4 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -5,40 +5,18 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color /** - * Semantic design tokens for the bitchat redesign. + * Bitchat-specific color tokens that do not have a faithful Material 3 semantic role. * - * 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. + * Standard backgrounds, surfaces, text, outlines, primary/secondary accents, and errors belong + * to [androidx.compose.material3.MaterialTheme.colorScheme]. Keeping only the extra app semantics + * here lets Material components inherit correct defaults without losing Bitchat's identity. */ @Immutable data class BitchatPalette( - /** Authoritative dark-mode flag. Prefer this over summing background channels. */ - val isDark: Boolean, - - // MARK: - Surfaces - /** Screen, header and composer background. True black in dark mode. */ - 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: - Form controls /** * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted - * [outline]: the composer is the one surface the user stares at while typing, and a green - * cast there made the whole bottom of the screen read as "terminal chrome" instead of a - * place to write. + * Material outline: the composer is the one surface the user stares at while typing. */ val inputOutline: Color, /** Border for a focused text input. A step brighter, still neutral. */ @@ -54,72 +32,45 @@ data class BitchatPalette( /** Resting disc behind the composer's action glyphs. Neutral grey. */ val inputButton: Color, - // MARK: - Text - /** Message bodies and row titles. Neutral, not green. */ - val textPrimary: Color, - /** Subtitles, secondary metadata, system/action messages. */ - val textSecondary: Color, + // MARK: - Extra semantics /** 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, + + // MARK: - Deterministic peer colors + /** Chroma applied after deriving a peer's stable hue. */ + val peerColorSaturation: Float, + /** Brightness applied after deriving a peer's stable hue. */ + val peerColorValue: Float, ) val DarkBitchatPalette = BitchatPalette( - isDark = true, - // True black. OLED panels switch these pixels off entirely, which is both the terminal - // aesthetic the app is going for and a real battery win on a screen that is mostly - // background. - background = Color(0xFF000000), - surface = Color(0xFF0E150E), - surfaceVariant = Color(0xFF182118), - outline = Color(0xFF2A3A2A), - outlineVariant = Color(0xFF1C271C), inputOutline = Color(0xFF333635), inputOutlineFocused = Color(0xFF5A605D), inputSurface = Color(0xFF0B0B0B), inputSurfaceFocused = Color(0xFF151515), inputButton = Color(0xFF1E1E1E), - textPrimary = Color(0xFFF5F5F5), - textSecondary = Color(0xFF9AA69A), textTertiary = Color(0xFF6B776B), - accentGreen = Color(0xFF32D74B), - accentBlue = Color(0xFF0A84FF), accentOrange = Color(0xFFFF9F0A), - accentRed = Color(0xFFFF453A), accentPurple = Color(0xFFBF5AF2), + peerColorSaturation = 1f, + peerColorValue = 1f, ) val LightBitchatPalette = BitchatPalette( - isDark = false, - background = Color(0xFFFFFFFF), - surface = Color(0xFFF2F6F2), - surfaceVariant = Color(0xFFE7EDE7), - outline = Color(0xFFCBD6CB), - outlineVariant = Color(0xFFDEE6DE), inputOutline = Color(0xFFCFD3D1), inputOutlineFocused = Color(0xFF8E9490), inputSurface = Color(0xFFFAFAFA), inputSurfaceFocused = Color(0xFFF2F2F2), inputButton = Color(0xFFE8E8E8), - textPrimary = Color(0xFF131A13), - textSecondary = Color(0xFF4C574C), textTertiary = Color(0xFF757F75), - accentGreen = Color(0xFF248A3D), - accentBlue = Color(0xFF007AFF), accentOrange = Color(0xFFFF9500), - accentRed = Color(0xFFD70015), accentPurple = Color(0xFFAF52DE), + peerColorSaturation = 0.85f, + peerColorValue = 0.45f, ) val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt new file mode 100644 index 00000000..2a0e0a38 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt @@ -0,0 +1,30 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.ui.graphics.Color +import com.bitchat.android.ui.PeerColorSeed +import kotlin.math.abs + +/** + * Resolve a peer's stable seed using the active Bitchat theme's explicit chroma tokens. + * + * The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is + * avoided because it is reserved for the current user. + */ +fun colorForPeerSeed(seed: PeerColorSeed, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in seed.value.toByteArray()) { + hash = ((hash shl 5) + hash) + byte.toUByte().toULong() + } + + var hue = (hash % 360UL).toDouble() / 360.0 + val orange = 30.0 / 360.0 + if (abs(hue - orange) < 0.05) { + hue = (hue + 0.12) % 1.0 + } + + return Color.hsv( + hue = (hue * 360).toFloat(), + saturation = palette.peerColorSaturation, + value = palette.peerColorValue + ) +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index 09d03959..f2c4b0b7 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -17,40 +17,51 @@ 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. -// 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) +// Standard UI semantics live in Material so stock components and custom Bitchat composables +// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors. +internal val DarkBitchatColorScheme = darkColorScheme( + primary = Color(0xFF32D74B), onPrimary = Color.Black, - secondary = Color(0xFF2ECB10), // Darker green + primaryContainer = Color(0xFF163D1D), + onPrimaryContainer = Color(0xFFB8F5C1), + secondary = Color(0xFF0A84FF), onSecondary = Color.Black, - background = DarkBitchatPalette.background, - onBackground = Color(0xFF39FF14), // Green on near-black - surface = DarkBitchatPalette.surface, - onSurface = Color(0xFF39FF14), // Green text - surfaceVariant = DarkBitchatPalette.surfaceVariant, - onSurfaceVariant = DarkBitchatPalette.textSecondary, - outline = DarkBitchatPalette.outline, - outlineVariant = DarkBitchatPalette.outlineVariant, - error = DarkBitchatPalette.accentRed, + secondaryContainer = Color(0xFF082E54), + onSecondaryContainer = Color(0xFFC2E0FF), + tertiary = DarkBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFF000000), + onBackground = Color(0xFFF5F5F5), + surface = Color(0xFF0E150E), + onSurface = Color(0xFFF5F5F5), + surfaceVariant = Color(0xFF182118), + onSurfaceVariant = Color(0xFF9AA69A), + outline = Color(0xFF2A3A2A), + outlineVariant = Color(0xFF1C271C), + error = Color(0xFFFF453A), onError = Color.Black ) -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF008000), // Dark green +internal val LightBitchatColorScheme = lightColorScheme( + primary = Color(0xFF248A3D), onPrimary = Color.White, - secondary = Color(0xFF006600), // Even darker green + primaryContainer = Color(0xFFD5F1D8), + onPrimaryContainer = Color(0xFF0A3212), + secondary = Color(0xFF007AFF), onSecondary = Color.White, - background = LightBitchatPalette.background, - onBackground = Color(0xFF008000), // Dark green on off-white - surface = LightBitchatPalette.surface, - onSurface = Color(0xFF008000), // Dark green text - surfaceVariant = LightBitchatPalette.surfaceVariant, - onSurfaceVariant = LightBitchatPalette.textSecondary, - outline = LightBitchatPalette.outline, - outlineVariant = LightBitchatPalette.outlineVariant, - error = LightBitchatPalette.accentRed, + secondaryContainer = Color(0xFFD6E9FF), + onSecondaryContainer = Color(0xFF002C5C), + tertiary = LightBitchatPalette.accentOrange, + onTertiary = Color.Black, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF131A13), + surface = Color(0xFFF2F6F2), + onSurface = Color(0xFF131A13), + surfaceVariant = Color(0xFFE7EDE7), + onSurfaceVariant = Color(0xFF4C574C), + outline = Color(0xFFCBD6CB), + outlineVariant = Color(0xFFDEE6DE), + error = Color(0xFFD70015), onError = Color.White ) @@ -71,7 +82,7 @@ fun BitchatTheme( } } - val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme + val colorScheme = if (shouldUseDark) DarkBitchatColorScheme else LightBitchatColorScheme val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette val view = LocalView.current diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index d58d3b4a..59f0eb73 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -7,16 +7,18 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.ui.theme.ChatVisualTokens +import com.bitchat.android.ui.theme.DarkBitchatColorScheme import com.bitchat.android.ui.theme.DarkBitchatPalette +import com.bitchat.android.ui.theme.LightBitchatColorScheme import com.bitchat.android.ui.theme.LightBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle +import com.bitchat.android.ui.theme.colorForPeerSeed 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 import org.junit.runner.RunWith @@ -33,6 +35,7 @@ class ChatUIUtilsTest { } private val palette = DarkBitchatPalette + private val colorScheme = DarkBitchatColorScheme private fun message( content: String, @@ -71,6 +74,8 @@ class ChatUIUtilsTest { message = message("hello there"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) @@ -89,6 +94,8 @@ class ChatUIUtilsTest { message = message("mined", powDifficulty = 8), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, ) @@ -106,6 +113,8 @@ class ChatUIUtilsTest { message = message("hello there"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -119,12 +128,14 @@ class ChatUIUtilsTest { message = message("plain words"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) val textStyle = body.spanStyles.first { it.start == 0 } - assertEquals(palette.textPrimary, textStyle.item.color) + assertEquals(colorScheme.onSurface, textStyle.item.color) } @Test @@ -146,6 +157,8 @@ class ChatUIUtilsTest { message = message("my own words", sender = "bob"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -170,6 +183,8 @@ class ChatUIUtilsTest { message = message("hey @carol#04af what's up"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -188,6 +203,8 @@ class ChatUIUtilsTest { message = message("ping @bob now"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -208,11 +225,13 @@ class ChatUIUtilsTest { message = message("cc @carol#04af"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) - val expected = colorForPeerSeed("carol#04af", palette.isDark) + val expected = colorForPeerSeed(PeerColorSeed("carol#04af"), palette) val chip = mentionChipSpans(body).single() assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) } @@ -223,6 +242,8 @@ class ChatUIUtilsTest { message = message("no mentions in here"), currentUserNickname = "bob", palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, timeFormatter = timeFormatter, includeTimestamp = false, ) @@ -236,7 +257,7 @@ class ChatUIUtilsTest { 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, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ).text @@ -249,7 +270,7 @@ class ChatUIUtilsTest { // messages despite being lower-priority narration. val annotated = formatSystemMessage( message = message("tor restarting", sender = "system"), - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ) @@ -260,7 +281,7 @@ class ChatUIUtilsTest { fun `system action and timestamp use their exported weights sizes and opacity`() { val annotated = formatSystemMessage( message = message("tor restarting", sender = "system"), - palette = palette, + contentColor = colorScheme.onSurface, timeFormatter = timeFormatter, ) @@ -273,10 +294,10 @@ class ChatUIUtilsTest { assertEquals(12.sp, action.fontSize) assertEquals(FontWeight.Medium, action.fontWeight) - assertEquals(palette.textPrimary.copy(alpha = 0.5f), action.color) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color) assertEquals(10.sp, time.fontSize) assertEquals(FontWeight.Normal, time.fontWeight) - assertEquals(palette.textPrimary.copy(alpha = 0.5f), time.color) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color) } // MARK: - Sender label @@ -327,12 +348,25 @@ class ChatUIUtilsTest { // MARK: - Peer colors + @Test + fun `peer seed factories normalize identities without resolving UI colors`() { + assertEquals( + PeerColorSeed("noise:abcdef"), + meshPeerColorSeed("ABCDEF") + ) + assertEquals( + PeerColorSeed("nostr:abcdef"), + nostrPeerColorSeed("ABCDEF") + ) + } + @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 seed = PeerColorSeed("noise:abc") + val dark = colorForPeerSeed(seed, DarkBitchatPalette) + val light = colorForPeerSeed(seed, LightBitchatPalette) val darkHsv = FloatArray(3) val lightHsv = FloatArray(3) @@ -342,13 +376,18 @@ class ChatUIUtilsTest { assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0) assertEquals(1.0, darkHsv[1].toDouble(), 0.01) assertEquals(1.0, darkHsv[2].toDouble(), 0.01) + assertEquals(0.85, lightHsv[1].toDouble(), 0.01) + assertEquals(0.45, lightHsv[2].toDouble(), 0.01) } @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 color = colorForPeerSeed( + PeerColorSeed("noise:seed$i"), + DarkBitchatPalette + ) val hsv = FloatArray(3) rgbToHsv(color.red, color.green, color.blue, hsv) val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f) @@ -360,11 +399,12 @@ class ChatUIUtilsTest { } @Test - fun `light palette is not the dark palette`() { - assertNull(null) - assertEquals(Color(0xFFF5F5F5), DarkBitchatPalette.textPrimary) - assertTrue(LightBitchatPalette.textPrimary != DarkBitchatPalette.textPrimary) - assertTrue(LightBitchatPalette.isDark != DarkBitchatPalette.isDark) + fun `material owns standard text while Bitchat palette owns peer chroma`() { + assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface) + assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface) + assertTrue( + LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue + ) } private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) { From 3054cd801e39b4303e3722d0399944be15a14efd Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:27:40 +0200 Subject: [PATCH 34/37] Centralize background power scheduling --- .../com/bitchat/android/BitchatApplication.kt | 7 + .../mesh/BluetoothConnectionManager.kt | 69 +-- .../mesh/BluetoothGattClientManager.kt | 56 +- .../android/mesh/BluetoothMeshService.kt | 24 - .../java/com/bitchat/android/mesh/MeshCore.kt | 16 - .../com/bitchat/android/mesh/PowerManager.kt | 572 ++++++++++-------- .../android/mesh/UnifiedMeshService.kt | 34 ++ .../com/bitchat/android/net/ArtiTorManager.kt | 49 +- .../android/nostr/NostrBackgroundRuntime.kt | 213 +++++++ .../android/nostr/NostrRelayManager.kt | 167 +++-- .../android/nostr/NostrSubscriptionManager.kt | 29 +- .../android/service/AppShutdownCoordinator.kt | 2 + .../android/service/MeshForegroundService.kt | 65 +- .../bitchat/android/services/AppStateStore.kt | 16 +- .../com/bitchat/android/ui/ChatViewModel.kt | 30 +- .../bitchat/android/ui/GeohashViewModel.kt | 169 +----- .../com/bitchat/android/util/AppConstants.kt | 2 +- .../android/wifi-aware/SyncedSocket.kt | 7 +- .../android/wifi-aware/WifiAwareController.kt | 42 +- .../wifi-aware/WifiAwareMeshService.kt | 114 ++-- .../android/mesh/PowerProfileResolverTest.kt | 102 ++++ .../android/services/AppStateStoreTest.kt | 4 + 22 files changed, 1056 insertions(+), 733 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 282f3295..ecd32327 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -13,6 +13,9 @@ class BitchatApplication : Application() { override fun onCreate() { super.onCreate() + // Start the single process-wide power policy before transport components are constructed. + com.bitchat.android.mesh.PowerManager.getInstance(this).start() + // Initialize Tor first so any early network goes over Tor try { val torProvider = ArtiTorManager.getInstance() @@ -53,6 +56,10 @@ class BitchatApplication : Application() { com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this) } catch (_: Exception) { } + // Own relay connectivity, selected-channel subscriptions, and presence scheduling at the + // process level so closing the Activity does not disconnect Nostr. + try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { } + // Initialize mesh service preferences try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 17e4ce28..35a3d9f4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -18,7 +18,7 @@ class BluetoothConnectionManager( private val context: Context, private val myPeerID: String, private val fragmentManager: FragmentManager? = null -) : PowerManagerDelegate { +) { companion object { private const val TAG = "BluetoothConnectionManager" @@ -30,7 +30,7 @@ class BluetoothConnectionManager( private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter // Power management - private val powerManager = PowerManager(context.applicationContext) + private val powerManager = PowerManager.getInstance(context.applicationContext) // Coroutines private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -117,7 +117,19 @@ class BluetoothConnectionManager( } init { - powerManager.delegate = this + connectionScope.launch { + var previousMode: PowerManager.PowerMode? = null + powerManager.profile.collect { profile -> + val modeChanged = previousMode != null && previousMode != profile.mode + previousMode = profile.mode + if (!isActive || !isBleTransportEnabled()) return@collect + + if (modeChanged && isGattServerEnabled()) { + serverManager.restartAdvertising() + } + clientManager.applyPowerProfile(profile) + } + } // Observe debug settings to enforce role state while active try { val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() @@ -294,9 +306,6 @@ class BluetoothConnectionManager( clientManager.stop() serverManager.stop() - // Stop power manager - powerManager.stop() - // Stop connection tracker connectionTracker.stop() @@ -451,54 +460,6 @@ class BluetoothConnectionManager( } } - // MARK: - PowerManagerDelegate Implementation - - override fun onPowerModeChanged(newMode: PowerManager.PowerMode) { - Log.i(TAG, "Power mode changed to: $newMode") - - connectionScope.launch { - if (!isActive || !isBleTransportEnabled()) { - serverManager.stop() - clientManager.stop() - return@launch - } - - // Avoid rapid scan restarts by checking if we need to change scan behavior - val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() - - // Update advertising with new power settings if server enabled - val serverEnabled = isGattServerEnabled() - if (serverEnabled) { - serverManager.restartAdvertising() - } else { - serverManager.stop() - } - - // Only restart scanning if the duty cycle behavior changed - val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() - if (wasUsingDutyCycle != nowUsingDutyCycle) { - val clientEnabled = isGattClientEnabled() - if (clientEnabled) { - clientManager.restartScanning() - } else { - clientManager.stop() - } - } - - // Enforce connection limits - enforceStrictLimits() - } - } - - override fun onScanStateChanged(shouldScan: Boolean) { - if (!isActive || !isBleTransportEnabled()) { - clientManager.onScanStateChanged(false) - return - } - clientManager.onScanStateChanged(shouldScan) - } - - // MARK: - Private Implementation - All moved to component managers } /** diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index 66971ea4..58bb1f48 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -93,6 +93,7 @@ class BluetoothGattClientManager( @Volatile private var lastScanResultTime = 0L private var scanRetryCount = 0 private var scanWatchdogJob: Job? = null + private var scanDutyCycleJob: Job? = null // RSSI monitoring state private var rssiMonitoringJob: Job? = null @@ -131,18 +132,9 @@ class BluetoothGattClientManager( isActive = true connectionScope.launch { - if (powerManager.shouldUseDutyCycle()) { - Log.i(TAG, "Using power-aware duty cycling") - // Duty cycle drives onScanStateChanged(true/false); scanningDesired follows that. - } else { - scanningDesired = true - startScanning() - } - + applyPowerProfile(powerManager.profile.value) // Start RSSI monitoring startRSSIMonitoring() - // Start the scan watchdog so a silently-dead or wedged scanner self-heals. - startScanWatchdog() } return true @@ -153,6 +145,8 @@ class BluetoothGattClientManager( */ fun stop() { scanningDesired = false + scanDutyCycleJob?.cancel() + scanDutyCycleJob = null stopScanWatchdog() if (!isActive) { // Idempotent stop @@ -208,10 +202,10 @@ class BluetoothGattClientManager( Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}") } } - delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS) + delay(powerManager.profile.value.ble.rssiPollIntervalMs) } catch (e: Exception) { Log.w(TAG, "Error in RSSI monitoring: ${e.message}") - delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS) + delay(powerManager.profile.value.ble.rssiPollIntervalMs) } } } @@ -659,13 +653,37 @@ class BluetoothGattClientManager( connectionScope.launch { stopScanning() delay(1000) // Extra delay to avoid rate limiting - - if (powerManager.shouldUseDutyCycle()) { - Log.i(TAG, "Switching to duty cycle scanning mode") - // Duty cycle will handle scanning - } else { - Log.i(TAG, "Switching to continuous scanning mode") - startScanning() + applyPowerProfile(powerManager.profile.value) + } + } + + /** + * Apply the current process-wide profile without ever disabling background discovery. + */ + fun applyPowerProfile(profile: PowerManager.RuntimePerformanceProfile) { + scanDutyCycleJob?.cancel() + scanDutyCycleJob = null + if (!isActive || !isClientRoleEnabled()) { + onScanStateChanged(false) + return + } + + if (profile.ble.continuousScan) { + startScanWatchdog() + onScanStateChanged(true) + return + } + + // Duty-cycled scans are re-armed every window, so the continuous-scan watchdog would only + // create background wakeups during intentional OFF periods. + stopScanWatchdog() + scanDutyCycleJob = connectionScope.launch { + while (isActive && isClientRoleEnabled()) { + onScanStateChanged(true) + delay(profile.ble.scanOnMs) + if (!isActive || !isClientRoleEnabled()) break + onScanStateChanged(false) + delay(profile.ble.scanOffMs) } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 55cdb6df..bdd49d8e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -126,7 +126,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic var delegate: BluetoothMeshDelegate? = null // Coroutines - private var announceJob: Job? = null // Tracks whether this instance has been terminated via stopServices() private var terminated = false private val provisionalBleClaims = @@ -217,23 +216,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } - /** - * Send broadcast announcement every 30 seconds - */ - private fun sendPeriodicBroadcastAnnounce() { - announceJob?.cancel() - announceJob = serviceScope.launch { - while (isActive) { - try { - delay(30000) // 30 seconds - sendBroadcastAnnounce() - } catch (e: Exception) { - Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") - } - } - } - } - /** * Setup delegate connections between components */ @@ -778,8 +760,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic isActive = true TransportBridgeService.register("BLE", this) - // Start periodic announcements for peer discovery and connectivity - sendPeriodicBroadcastAnnounce() // Start periodic syncs com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE") } else { @@ -801,8 +781,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic private fun pauseServicesForTransportDisable() { Log.i(TAG, "Disabling BLE mesh transport") isActive = false - announceJob?.cancel() - announceJob = null com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE") TransportBridgeService.unregister("BLE") try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } @@ -822,8 +800,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic Log.i(TAG, "Stopping Bluetooth mesh service") isActive = false - announceJob?.cancel() - announceJob = null TransportBridgeService.unregister("BLE") try { com.bitchat.android.services.AppStateStore.clearTransportPeers("BLE") } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("BLE") } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 375e6531..0c963cc1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -116,7 +116,6 @@ class MeshCore( var delegate: MeshDelegate? = null - private var announceJob: Job? = null private var isActive = false init { @@ -145,7 +144,6 @@ class MeshCore( fun startCore() { if (isActive) return isActive = true - startPeriodicBroadcastAnnounce() if (ownsGossipManager) { gossipSyncManager.start() } @@ -154,8 +152,6 @@ class MeshCore( fun stopCore() { if (!isActive) return isActive = false - announceJob?.cancel() - announceJob = null if (ownsGossipManager) { gossipSyncManager.stop() } @@ -195,18 +191,6 @@ class MeshCore( TransportBridgeService.broadcast(transport.id, routed) } - private fun startPeriodicBroadcastAnnounce() { - announceJob?.cancel() - announceJob = scope.launch { - while (isActive) { - try { - delay(30_000) - sendBroadcastAnnounce() - } catch (_: Exception) { } - } - } - } - private fun setupDelegates() { peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt index 3096828a..9714c63a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -14,327 +14,261 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import kotlinx.coroutines.* -import kotlin.math.max +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.util.AppConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** - * Power-aware Bluetooth management for bitchat - * Adjusts scanning, advertising, and connection behavior based on battery state + * Process-wide source of truth for background and battery-aware scheduling. + * + * The manager deliberately exposes policy only. Consumers retain ownership of their hardware and + * network jobs, so changing profile never implies disconnecting a transport or suspending Tor. */ -class PowerManager(private val context: Context) : LifecycleEventObserver { - +class PowerManager private constructor(context: Context) : LifecycleEventObserver { + + enum class PowerMode { + PERFORMANCE, + BALANCED, + POWER_SAVER, + ULTRA_LOW_POWER + } + + enum class BatteryBand { NORMAL, LOW, CRITICAL } + + data class BleSchedule( + val scanOnMs: Long, + val scanOffMs: Long, + val continuousScan: Boolean, + val rssiPollIntervalMs: Long, + val maxConnections: Int = 8 + ) + + data class WifiAwareSchedule( + val tcpKeepAliveMs: Long, + val discoveryKeepAliveMs: Long, + val connectionMaintenanceMs: Long, + val discoveryIdleRefreshMs: Long, + val discoverySessionRefreshMinMs: Long, + val discoveryStaleMs: Long + ) + + data class NostrSchedule( + val presenceHeartbeatMinMs: Long, + val presenceHeartbeatMaxMs: Long, + val subscriptionValidationMs: Long + ) + + data class RuntimePerformanceProfile( + val mode: PowerMode, + val batteryBand: BatteryBand, + val isBackground: Boolean, + val isCharging: Boolean, + val hasDirectPeers: Boolean, + val ble: BleSchedule, + val meshAnnouncementIntervalMs: Long, + val wifiAware: WifiAwareSchedule, + val nostr: NostrSchedule + ) + companion object { private const val TAG = "PowerManager" - - // Battery thresholds - private const val CRITICAL_BATTERY = com.bitchat.android.util.AppConstants.Power.CRITICAL_BATTERY_PERCENT - private const val LOW_BATTERY = com.bitchat.android.util.AppConstants.Power.LOW_BATTERY_PERCENT - private const val MEDIUM_BATTERY = com.bitchat.android.util.AppConstants.Power.MEDIUM_BATTERY_PERCENT - - // Scan duty cycle periods (ms) - private const val SCAN_ON_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_NORMAL_MS // 8 seconds on - private const val SCAN_OFF_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_NORMAL_MS // 2 seconds off - private const val SCAN_ON_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_POWER_SAVE_MS // 2 seconds on - private const val SCAN_OFF_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_POWER_SAVE_MS // 8 seconds off - private const val SCAN_ON_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_ULTRA_LOW_MS // 1 second on - private const val SCAN_OFF_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_ULTRA_LOW_MS // 10 seconds off - - // Connection limits - private const val MAX_CONNECTIONS_NORMAL = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_NORMAL - private const val MAX_CONNECTIONS_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_POWER_SAVE - private const val MAX_CONNECTIONS_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_ULTRA_LOW + + @Volatile + private var INSTANCE: PowerManager? = null + + fun getInstance(context: Context): PowerManager = + INSTANCE ?: synchronized(this) { + INSTANCE ?: PowerManager(context.applicationContext).also { INSTANCE = it } + } } - - enum class PowerMode { - PERFORMANCE, // Full power, no restrictions - BALANCED, // Moderate power saving - POWER_SAVER, // Aggressive power saving - ULTRA_LOW_POWER // Minimal operations only - } - - private var currentMode = PowerMode.BALANCED - private var isCharging = false - private var batteryLevel = 100 - private var isAppInBackground = true - - private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var dutyCycleJob: Job? = null - - var delegate: PowerManagerDelegate? = null - - // Battery monitoring + + private val appContext = context.applicationContext + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @Volatile private var batteryLevel = 100 + @Volatile private var isCharging = false + @Volatile private var isAppInBackground = true + @Volatile private var hasDirectPeers = false + @Volatile private var shutdown = false + + private val _profile = MutableStateFlow( + PowerProfileResolver.resolve( + batteryLevel = batteryLevel, + isCharging = isCharging, + isBackground = isAppInBackground, + hasDirectPeers = hasDirectPeers + ) + ) + val profile: StateFlow = _profile.asStateFlow() + private val batteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { Intent.ACTION_BATTERY_CHANGED -> { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) - if (level != -1 && scale != -1) { - batteryLevel = (level * 100) / scale - } - + if (level >= 0 && scale > 0) batteryLevel = (level * 100) / scale + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || - status == BatteryManager.BATTERY_STATUS_FULL - - updatePowerMode() + status == BatteryManager.BATTERY_STATUS_FULL + refreshProfile() } Intent.ACTION_POWER_CONNECTED -> { isCharging = true - updatePowerMode() + refreshProfile() } Intent.ACTION_POWER_DISCONNECTED -> { isCharging = false - updatePowerMode() + refreshProfile() } } } } - + init { registerBatteryReceiver() - - // Register for process lifecycle events on the main thread Handler(Looper.getMainLooper()).post { try { ProcessLifecycleOwner.get().lifecycle.addObserver(this) + isAppInBackground = !ProcessLifecycleOwner.get().lifecycle.currentState + .isAtLeast(Lifecycle.State.STARTED) + refreshProfile() } catch (e: Exception) { - Log.e(TAG, "Failed to register lifecycle observer: ${e.message}") + Log.e(TAG, "Failed to register process lifecycle observer: ${e.message}") } } - - updatePowerMode() + scope.launch { + AppStateStore.directPeers + .collect { peers -> + hasDirectPeers = peers.isNotEmpty() + refreshProfile() + } + } } - - fun start() { - Log.i(TAG, "Starting power management") - startDutyCycle() - } - - fun stop() { - Log.i(TAG, "Stopping power management") - powerScope.cancel() - unregisterBatteryReceiver() - - // Unregister lifecycle observer + + /** Kept for existing callers; initialization is eager and idempotent. */ + fun start() = Unit + + /** + * Process-level teardown for explicit full application shutdown only. + * Transport stop/restart paths must not call this. + */ + fun shutdown() { + if (shutdown) return + shutdown = true + scope.cancel() + try { appContext.unregisterReceiver(batteryReceiver) } catch (_: Exception) { } Handler(Looper.getMainLooper()).post { - try { - ProcessLifecycleOwner.get().lifecycle.removeObserver(this) - } catch (e: Exception) { - Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}") - } + try { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } catch (_: Exception) { } } } - + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_START -> { - Log.d(TAG, "Process lifecycle: ON_START (App coming to foreground)") isAppInBackground = false - updatePowerMode() + refreshProfile() } Lifecycle.Event.ON_STOP -> { - Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)") isAppInBackground = true - updatePowerMode() + refreshProfile() } - else -> {} + else -> Unit } } - - /** - * Get scan settings optimized for current power mode - */ + fun getScanSettings(): ScanSettings { + val current = profile.value val builder = ScanSettings.Builder() .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) - when (currentMode) { + when (current.mode) { PowerMode.PERFORMANCE -> builder .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) - PowerMode.BALANCED -> builder .setScanMode(ScanSettings.SCAN_MODE_BALANCED) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) - - PowerMode.POWER_SAVER -> builder - .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) - .setMatchMode(ScanSettings.MATCH_MODE_STICKY) - .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) - + PowerMode.POWER_SAVER, PowerMode.ULTRA_LOW_POWER -> builder .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .setMatchMode(ScanSettings.MATCH_MODE_STICKY) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) } - return builder.setReportDelay(0).build() } - - /** - * Get advertising settings optimized for current power mode - */ - fun getAdvertiseSettings(): AdvertiseSettings { - return when (currentMode) { - PowerMode.PERFORMANCE -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.BALANCED -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.POWER_SAVER -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW) - .setConnectable(true) - .setTimeout(0) - .build() - - PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder() - .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) - .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW) - .setConnectable(true) - .setTimeout(0) - .build() - } + + fun getAdvertiseSettings(): AdvertiseSettings = when (profile.value.mode) { + PowerMode.PERFORMANCE -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setConnectable(true).setTimeout(0).build() + PowerMode.BALANCED -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .setConnectable(true).setTimeout(0).build() + PowerMode.POWER_SAVER -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW) + .setConnectable(true).setTimeout(0).build() + PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW) + .setConnectable(true).setTimeout(0).build() } - - /** - * Get maximum allowed connections for current power mode - */ - fun getMaxConnections(): Int { - return when (currentMode) { - PowerMode.PERFORMANCE -> MAX_CONNECTIONS_NORMAL - PowerMode.BALANCED -> MAX_CONNECTIONS_NORMAL - PowerMode.POWER_SAVER -> MAX_CONNECTIONS_POWER_SAVE - PowerMode.ULTRA_LOW_POWER -> MAX_CONNECTIONS_ULTRA_LOW - } + + fun getMaxConnections(): Int = profile.value.ble.maxConnections + + fun getRSSIThreshold(): Int = when (profile.value.mode) { + PowerMode.PERFORMANCE -> -95 + PowerMode.BALANCED -> -85 + PowerMode.POWER_SAVER -> -75 + PowerMode.ULTRA_LOW_POWER -> -65 } - - /** - * Get RSSI filter threshold for current power mode - */ - fun getRSSIThreshold(): Int { - return when (currentMode) { - PowerMode.PERFORMANCE -> -95 - PowerMode.BALANCED -> -85 - PowerMode.POWER_SAVER -> -75 - PowerMode.ULTRA_LOW_POWER -> -65 - } - } - - /** - * Check if duty cycling should be used - */ - fun shouldUseDutyCycle(): Boolean { - return currentMode != PowerMode.PERFORMANCE - } - - /** - * Get current power mode information - */ - fun getPowerInfo(): String { - return buildString { + + fun shouldUseDutyCycle(): Boolean = !profile.value.ble.continuousScan + + fun getPowerInfo(): String = profile.value.let { current -> + buildString { appendLine("=== Power Manager Status ===") - appendLine("Current Mode: $currentMode") - appendLine("Battery Level: $batteryLevel%") - appendLine("Is Charging: $isCharging") - appendLine("App In Background: $isAppInBackground") - appendLine("Max Connections: ${getMaxConnections()}") - appendLine("RSSI Threshold: ${getRSSIThreshold()} dBm") - appendLine("Use Duty Cycle: ${shouldUseDutyCycle()}") + appendLine("Current Mode: ${current.mode}") + appendLine("Battery Band: ${current.batteryBand} ($batteryLevel%)") + appendLine("Is Charging: ${current.isCharging}") + appendLine("App In Background: ${current.isBackground}") + appendLine("Has Direct Peers: ${current.hasDirectPeers}") + appendLine("BLE Scan: ${current.ble.scanOnMs}ms ON / ${current.ble.scanOffMs}ms OFF") + appendLine("Max Connections: ${current.ble.maxConnections}") } } - - private fun updatePowerMode() { - // Determine the base mode from battery/charging state only - val baseMode = when { - // Charging in foreground may use performance - isCharging && !isAppInBackground -> PowerMode.PERFORMANCE - // Critical battery - force ultra low power regardless of foreground/background - batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER - - // Low battery - prefer power saver - batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER - - // Otherwise balanced - else -> PowerMode.BALANCED - } - - // If app is in background (including when running as a foreground service), - // cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER. - val newMode = if (isAppInBackground) { - if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER - } else { - baseMode - } - - if (newMode != currentMode) { - val oldMode = currentMode - currentMode = newMode - Log.i(TAG, "Power mode changed: $oldMode → $newMode (battery: $batteryLevel%, charging: $isCharging, background: $isAppInBackground)") - - delegate?.onPowerModeChanged(currentMode) - - // Restart duty cycle with new parameters - if (shouldUseDutyCycle()) { - startDutyCycle() - } else { - stopDutyCycle() - } + private fun refreshProfile() { + if (shutdown) return + val next = PowerProfileResolver.resolve( + batteryLevel = batteryLevel, + isCharging = isCharging, + isBackground = isAppInBackground, + hasDirectPeers = hasDirectPeers + ) + if (_profile.value != next) { + Log.i( + TAG, + "Profile changed: mode=${next.mode}, battery=${next.batteryBand}, " + + "background=${next.isBackground}, directPeers=${next.hasDirectPeers}" + ) + _profile.value = next } } - - private fun startDutyCycle() { - stopDutyCycle() - - if (!shouldUseDutyCycle()) { - delegate?.onScanStateChanged(true) // Always scan in performance mode - return - } - - val (onDuration, offDuration) = when (currentMode) { - PowerMode.BALANCED -> SCAN_ON_DURATION_NORMAL to SCAN_OFF_DURATION_NORMAL - PowerMode.POWER_SAVER -> SCAN_ON_DURATION_POWER_SAVE to SCAN_OFF_DURATION_POWER_SAVE - PowerMode.ULTRA_LOW_POWER -> SCAN_ON_DURATION_ULTRA_LOW to SCAN_OFF_DURATION_ULTRA_LOW - PowerMode.PERFORMANCE -> return // No duty cycle - } - - dutyCycleJob = powerScope.launch { - while (isActive && shouldUseDutyCycle()) { - // Scan ON period - Log.d(TAG, "Duty cycle: Scan ON for ${onDuration}ms") - delegate?.onScanStateChanged(true) - delay(onDuration) - - // Scan OFF period (keep advertising active) - if (isActive && shouldUseDutyCycle()) { - Log.d(TAG, "Duty cycle: Scan OFF for ${offDuration}ms") - delegate?.onScanStateChanged(false) - delay(offDuration) - } - } - } - - Log.i(TAG, "Started duty cycle: ${onDuration}ms ON, ${offDuration}ms OFF") - } - - private fun stopDutyCycle() { - dutyCycleJob?.cancel() - dutyCycleJob = null - } - + private fun registerBatteryReceiver() { try { val filter = IntentFilter().apply { @@ -342,25 +276,141 @@ class PowerManager(private val context: Context) : LifecycleEventObserver { addAction(Intent.ACTION_POWER_CONNECTED) addAction(Intent.ACTION_POWER_DISCONNECTED) } - context.registerReceiver(batteryReceiver, filter) + appContext.registerReceiver(batteryReceiver, filter) } catch (e: Exception) { Log.w(TAG, "Failed to register battery receiver: ${e.message}") } } - - private fun unregisterBatteryReceiver() { - try { - context.unregisterReceiver(batteryReceiver) - } catch (e: Exception) { - Log.w(TAG, "Failed to unregister battery receiver: ${e.message}") - } - } } /** - * Delegate interface for power management callbacks + * Pure policy resolver so cadence and battery-boundary behavior can be tested without Android. */ -interface PowerManagerDelegate { - fun onPowerModeChanged(newMode: PowerManager.PowerMode) - fun onScanStateChanged(shouldScan: Boolean) +internal object PowerProfileResolver { + fun resolve( + batteryLevel: Int, + isCharging: Boolean, + isBackground: Boolean, + hasDirectPeers: Boolean + ): PowerManager.RuntimePerformanceProfile { + val batteryBand = when { + batteryLevel <= AppConstants.Power.CRITICAL_BATTERY_PERCENT -> + PowerManager.BatteryBand.CRITICAL + batteryLevel <= AppConstants.Power.LOW_BATTERY_PERCENT -> + PowerManager.BatteryBand.LOW + else -> PowerManager.BatteryBand.NORMAL + } + + val mode = when { + isBackground && batteryBand == PowerManager.BatteryBand.CRITICAL -> + PowerManager.PowerMode.ULTRA_LOW_POWER + isBackground -> PowerManager.PowerMode.POWER_SAVER + isCharging -> PowerManager.PowerMode.PERFORMANCE + batteryBand == PowerManager.BatteryBand.CRITICAL -> + PowerManager.PowerMode.ULTRA_LOW_POWER + batteryBand == PowerManager.BatteryBand.LOW -> + PowerManager.PowerMode.POWER_SAVER + else -> PowerManager.PowerMode.BALANCED + } + + val ble = when { + isBackground && hasDirectPeers -> + PowerManager.BleSchedule( + scanOnMs = 1_000L, + scanOffMs = 29_000L, + continuousScan = false, + rssiPollIntervalMs = backgroundRssiInterval(batteryBand) + ) + isBackground -> + PowerManager.BleSchedule( + scanOnMs = 1_000L, + scanOffMs = 59_000L, + continuousScan = false, + rssiPollIntervalMs = backgroundRssiInterval(batteryBand) + ) + mode == PowerManager.PowerMode.PERFORMANCE -> + PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true, 5_000L) + mode == PowerManager.PowerMode.BALANCED -> + PowerManager.BleSchedule(8_000L, 2_000L, false, 10_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.BleSchedule(2_000L, 28_000L, false, 30_000L) + else -> + PowerManager.BleSchedule(1_000L, 29_000L, false, 60_000L) + } + + val announcementInterval = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> 60_000L + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> 120_000L + isBackground -> 300_000L + mode == PowerManager.PowerMode.POWER_SAVER -> 60_000L + mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> 120_000L + else -> 30_000L + } + + val wifi = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> + wifi(10, 60, 60, 5, 5, 10) + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> + wifi(20, 120, 120, 10, 10, 15) + isBackground -> + wifi(30, 180, 180, 15, 15, 20) + mode == PowerManager.PowerMode.PERFORMANCE -> + PowerManager.WifiAwareSchedule(2_000L, 20_000L, 15_000L, 120_000L, 90_000L, 300_000L) + mode == PowerManager.PowerMode.BALANCED -> + PowerManager.WifiAwareSchedule(5_000L, 30_000L, 30_000L, 180_000L, 120_000L, 300_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.WifiAwareSchedule(10_000L, 60_000L, 60_000L, 300_000L, 180_000L, 600_000L) + else -> + PowerManager.WifiAwareSchedule(15_000L, 90_000L, 90_000L, 600_000L, 300_000L, 900_000L) + } + + val nostr = when { + isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> + PowerManager.NostrSchedule(300_000L, 330_000L, 300_000L) + isBackground && batteryBand == PowerManager.BatteryBand.LOW -> + PowerManager.NostrSchedule(600_000L, 660_000L, 600_000L) + isBackground -> + PowerManager.NostrSchedule(900_000L, 990_000L, 900_000L) + mode == PowerManager.PowerMode.POWER_SAVER -> + PowerManager.NostrSchedule(120_000L, 132_000L, 60_000L) + mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> + PowerManager.NostrSchedule(300_000L, 330_000L, 120_000L) + else -> + PowerManager.NostrSchedule(40_000L, 80_000L, 30_000L) + } + + return PowerManager.RuntimePerformanceProfile( + mode = mode, + batteryBand = batteryBand, + isBackground = isBackground, + isCharging = isCharging, + hasDirectPeers = hasDirectPeers, + ble = ble, + meshAnnouncementIntervalMs = announcementInterval, + wifiAware = wifi, + nostr = nostr + ) + } + + private fun wifi( + tcpSeconds: Long, + discoverySeconds: Long, + maintenanceSeconds: Long, + idleMinutes: Long, + minRefreshMinutes: Long, + staleMinutes: Long + ) = PowerManager.WifiAwareSchedule( + tcpKeepAliveMs = tcpSeconds * 1_000L, + discoveryKeepAliveMs = discoverySeconds * 1_000L, + connectionMaintenanceMs = maintenanceSeconds * 1_000L, + discoveryIdleRefreshMs = idleMinutes * 60_000L, + discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L, + discoveryStaleMs = staleMinutes * 60_000L + ) + + private fun backgroundRssiInterval(band: PowerManager.BatteryBand): Long = when (band) { + PowerManager.BatteryBand.NORMAL -> 60_000L + PowerManager.BatteryBand.LOW -> 120_000L + PowerManager.BatteryBand.CRITICAL -> 300_000L + } } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index ca49def9..b9f84547 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -6,7 +6,17 @@ import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession +import com.bitchat.android.services.AppStateStore import com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** * Feature-facing mesh service that hides local transport selection from the rest of the app. @@ -24,6 +34,10 @@ class UnifiedMeshService( private const val TAG = "UnifiedMeshService" } + private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val powerManager = PowerManager.getInstance(context.applicationContext) + private var announcementJob: Job? = null + override val myPeerID: String get() = bluetooth.myPeerID @@ -49,14 +63,34 @@ class UnifiedMeshService( try { WifiAwareController.startIfPossible() } catch (e: Exception) { Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}") } + startAnnouncementScheduler() refreshDelegates() } override fun stopServices() { + announcementJob?.cancel() + announcementJob = null try { bluetooth.stopServices() } catch (_: Exception) { } try { WifiAwareController.stop() } catch (_: Exception) { } } + private fun startAnnouncementScheduler() { + if (announcementJob?.isActive == true) return + announcementJob = serviceScope.launch { + combine(powerManager.profile, AppStateStore.directPeers) { profile, peers -> + profile.meshAnnouncementIntervalMs to peers.isNotEmpty() + }.collectLatest { (intervalMs, hasRecipients) -> + if (!hasRecipients) return@collectLatest + // Connection-specific paths already send an immediate announce. Begin the + // periodic cadence after the configured interval to avoid a transition burst. + while (isActive) { + delay(intervalMs) + if (AppStateStore.directPeers.value.isNotEmpty()) sendBroadcastAnnounce() + } + } + } + } + override fun sendMessage(content: String, mentions: List, channel: String?) { when { isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel) diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index d560c648..b1eed65e 100644 --- a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -133,6 +133,9 @@ class ArtiTorManager private constructor() { lastLogTime.set(System.currentTimeMillis()) _statusFlow.update { it.copy(lastLogLine = s) } handleArtiLogLine(s) + if (_statusFlow.value.bootstrapPercent < 100) { + armBootstrapInactivityWatchdog() + } } artiProxy = ArtiProxy.Builder(application) @@ -374,29 +377,30 @@ class ArtiTorManager private constructor() { } private fun startInactivityMonitoring() { - inactivityJob?.cancel() - inactivityJob = appScope.launch { - while (true) { - delay(INACTIVITY_TIMEOUT_MS) - val currentTime = System.currentTimeMillis() - val lastActivity = lastLogTime.get() - val timeSinceLastActivity = currentTime - lastActivity + armBootstrapInactivityWatchdog() + } - if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { - val currentMode = _statusFlow.value.mode - if (currentMode == TorMode.ON) { - val bootstrapPercent = _statusFlow.value.bootstrapPercent - if (bootstrapPercent < 100) { - Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") - currentApplication?.let { app -> - appScope.launch { - restartArti(app) - } - } - break - } - } - } + /** + * One-shot startup watchdog. Arti log activity re-arms it and successful bootstrap cancels it, + * so a healthy background Tor process has no permanent five-second polling coroutine. + */ + private fun armBootstrapInactivityWatchdog() { + inactivityJob?.cancel() + if (lifecycleState != LifecycleState.RUNNING || _statusFlow.value.bootstrapPercent >= 100) { + inactivityJob = null + return + } + inactivityJob = appScope.launch { + delay(INACTIVITY_TIMEOUT_MS) + val timeSinceLastActivity = System.currentTimeMillis() - lastLogTime.get() + if ( + timeSinceLastActivity >= INACTIVITY_TIMEOUT_MS && + _statusFlow.value.mode == TorMode.ON && + _statusFlow.value.bootstrapPercent < 100 && + lifecycleState == LifecycleState.RUNNING + ) { + Log.w(TAG, "Bootstrap inactivity detected (${timeSinceLastActivity}ms), restarting Arti") + currentApplication?.let { restartArti(it) } } } } @@ -494,6 +498,7 @@ class ArtiTorManager private constructor() { running = true ) } + stopInactivityMonitoring() completeWaitersIf(TorState.RUNNING) } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt new file mode 100644 index 00000000..8abb4d6a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -0,0 +1,213 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.mesh.PowerManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.security.SecureRandom +import kotlin.random.asKotlinRandom + +/** + * Process-owned Nostr connectivity and low-volume background subscriptions. + * + * Stable subscription handlers delegate to the most recently attached UI-independent handlers. + * This avoids relay subscriptions retaining a cleared ViewModel while keeping DMs and the selected + * geohash channel alive for the lifetime of the foreground-service process. + */ +object NostrBackgroundRuntime { + private const val TAG = "NostrBackground" + private const val MAX_PENDING_EVENTS = 256 + + data class Handlers( + val accountDm: (NostrEvent, NostrIdentity) -> Unit, + val geohashMessage: (NostrEvent, String) -> Unit, + val geohashDm: (NostrEvent, String, NostrIdentity) -> Unit + ) + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + internal val eventScope: CoroutineScope + get() = scope + private val random = SecureRandom().asKotlinRandom() + private val lock = Any() + private val pendingEvents = ArrayDeque<(Handlers) -> Unit>() + + @Volatile private var handlers: Handlers? = null + @Volatile private var initialized = false + @Volatile private var activeGeohash: String? = null + @Volatile private var conversationGeohash: String? = null + private lateinit var application: Application + private lateinit var subscriptions: NostrSubscriptionManager + private lateinit var locationChannels: LocationChannelManager + + fun initialize(app: Application) { + synchronized(lock) { + if (initialized) return + initialized = true + application = app + locationChannels = LocationChannelManager.getInstance(app) + subscriptions = NostrSubscriptionManager( + app, + scope, + owner = NostrRelayManager.OWNER_BACKGROUND + ) + } + + subscriptions.connect() + subscribeAccountDm() + observeSelectedChannel() + startPresenceScheduler() + } + + fun attachHandlers(newHandlers: Handlers) { + handlers = newHandlers + val pending = synchronized(lock) { + pendingEvents.toList().also { pendingEvents.clear() } + } + pending.forEach { event -> runCatching { event(newHandlers) } } + } + + fun resetSubscriptions() { + if (!initialized) return + subscriptions.unsubscribeAllOwned() + scope.launch { + // Let CLOSE frames be queued before replacing the deterministic IDs. + delay(100) + subscribeAccountDm() + activeGeohash?.let(::subscribeSelectedGeohash) + } + } + + fun ensureConversationDm(geohash: String) { + if (!initialized || geohash == activeGeohash || geohash == conversationGeohash) return + conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") } + conversationGeohash = geohash + subscribeGeohashDm(geohash, "geo-dm-conversation-$geohash") + } + + private fun subscribeAccountDm() { + val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = "chat-messages", + handler = { event -> + dispatch { it.accountDm(event, identity) } + } + ) + } + + private fun observeSelectedChannel() { + scope.launch { + locationChannels.selectedChannel.collectLatest { channel -> + val next = (channel as? ChannelID.Location)?.channel?.geohash + val previous = activeGeohash + if (previous == next) return@collectLatest + + previous?.let { + subscriptions.unsubscribe("geohash-$it") + subscriptions.unsubscribe("geo-dm-$it") + } + activeGeohash = next + if (conversationGeohash == next) { + subscriptions.unsubscribe("geo-dm-conversation-$next") + conversationGeohash = null + } + next?.let(::subscribeSelectedGeohash) + } + } + } + + private fun subscribeSelectedGeohash(geohash: String) { + subscriptions.subscribeGeohashMessages( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3_600_000L, + limit = 200, + id = "geohash-$geohash", + handler = { event -> dispatch { it.geohashMessage(event, geohash) } } + ) + subscribeGeohashDm(geohash, "geo-dm-$geohash") + } + + private fun subscribeGeohashDm(geohash: String, subscriptionId: String) { + scope.launch { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = subscriptionId, + handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } } + ) + GeohashAliasRegistry.put( + "nostr_${identity.publicKeyHex.take(16)}", + identity.publicKeyHex + ) + } + } + + private fun startPresenceScheduler() { + val powerManager = PowerManager.getInstance(application) + scope.launch { + val nostrSchedule = powerManager.profile + .map { it.nostr } + .distinctUntilChanged() + combine(locationChannels.availableChannels, nostrSchedule) { channels, schedule -> + val targets = channels + .filter { it.level.precision <= 5 } + .map { it.geohash } + .distinct() + targets to schedule + }.collectLatest { (targets, schedule) -> + if (targets.isEmpty()) return@collectLatest + while (true) { + val waitMs = if (schedule.presenceHeartbeatMaxMs > schedule.presenceHeartbeatMinMs) { + random.nextLong( + schedule.presenceHeartbeatMinMs, + schedule.presenceHeartbeatMaxMs + 1 + ) + } else { + schedule.presenceHeartbeatMinMs + } + delay(waitMs) + + // Send every target in one wake window; do not spread the batch over seconds. + targets.forEach { geohash -> + try { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) + NostrRelayManager.getInstance(application).sendEventToGeohash( + event, + geohash, + includeDefaults = false, + nRelays = 5 + ) + } catch (e: Exception) { + Log.w(TAG, "Presence heartbeat failed for $geohash: ${e.message}") + } + } + } + } + } + } + + private fun dispatch(event: (Handlers) -> Unit) { + val current = handlers + if (current != null) { + event(current) + return + } + synchronized(lock) { + if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.removeFirst() + pendingEvents.addLast(event) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index f50d65cc..20a1ce18 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.* import okhttp3.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.min import kotlin.math.pow @@ -25,11 +26,15 @@ class NostrRelayManager private constructor() { val shared = NostrRelayManager() private const val TAG = "NostrRelayManager" + private const val MAX_QUEUED_EVENTS = 500 + const val OWNER_LEGACY = "legacy" + const val OWNER_BACKGROUND = "background" /** * Get instance for Android compatibility (context-aware calls) */ fun getInstance(context: android.content.Context): NostrRelayManager { + shared.appContext = context.applicationContext return shared } @@ -82,6 +87,8 @@ class NostrRelayManager private constructor() { // Internal state private val relaysList = mutableListOf() private val connections = ConcurrentHashMap() + private val reconnectJobs = ConcurrentHashMap() + private val desiredConnected = AtomicBoolean(false) private val subscriptions = ConcurrentHashMap>() // relay URL -> subscription IDs private val messageHandlers = ConcurrentHashMap Unit>() @@ -97,14 +104,19 @@ class NostrRelayManager private constructor() { val handler: (NostrEvent) -> Unit, val targetRelayUrls: Set? = null, // null means all relays val createdAt: Long = System.currentTimeMillis(), - val originGeohash: String? = null // used for logging and grouping + val originGeohash: String? = null, // used for logging and grouping + val owner: String = OWNER_LEGACY ) // Event deduplication system private val eventDeduplicator = NostrEventDeduplicator.getInstance() // Message queue for reliability - private val messageQueue = mutableListOf>>() + private data class QueuedEvent( + val event: NostrEvent, + val pendingRelayUrls: MutableSet + ) + private val messageQueue = mutableListOf() private val messageQueueLock = Any() // Coroutine scope for background operations @@ -112,7 +124,9 @@ class NostrRelayManager private constructor() { // Subscription validation timer private var subscriptionValidationJob: Job? = null - private val SUBSCRIPTION_VALIDATION_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS // 30 seconds + private val powerManager: com.bitchat.android.mesh.PowerManager? + get() = appContext?.let { com.bitchat.android.mesh.PowerManager.getInstance(it) } + @Volatile private var appContext: android.content.Context? = null // OkHttp client for WebSocket connections (via provider to honor Tor) private val httpClient: OkHttpClient @@ -162,7 +176,8 @@ class NostrRelayManager private constructor() { id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, includeDefaults: Boolean = false, - nRelays: Int = 5 + nRelays: Int = 5, + owner: String = OWNER_LEGACY ): String { ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults) val relayUrls = getRelaysForGeohash(geohash) @@ -170,7 +185,8 @@ class NostrRelayManager private constructor() { filter = filter, id = id, handler = handler, - targetRelayUrls = relayUrls + targetRelayUrls = relayUrls, + owner = owner ).also { // update origin geohash for this subscription activeSubscriptions[it]?.let { sub -> @@ -204,6 +220,7 @@ class NostrRelayManager private constructor() { } updateRelaysList() + if (!desiredConnected.get()) return scope.launch { relayUrls.forEach { relayUrl -> launch { @@ -239,6 +256,7 @@ class NostrRelayManager private constructor() { * Connect to all configured relays */ fun connect() { + desiredConnected.set(true) Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays") scope.launch { @@ -258,18 +276,26 @@ class NostrRelayManager private constructor() { */ fun disconnect() { Log.i(TAG, "Disconnecting from all Nostr relays") + desiredConnected.set(false) // Stop subscription validation stopSubscriptionValidation() + reconnectJobs.values.forEach { it.cancel() } + reconnectJobs.clear() - connections.values.forEach { webSocket -> + val sockets = connections.values.toList() + connections.clear() + sockets.forEach { webSocket -> webSocket.close(1000, "Manual disconnect") } - connections.clear() - // Clear subscriptions + // Preserve logical subscriptions for controlled resets, but forget per-socket state. subscriptions.clear() - + relaysList.forEach { + it.isConnected = false + it.nextReconnectTime = null + } + updateRelaysList() updateConnectionStatus() } @@ -281,7 +307,8 @@ class NostrRelayManager private constructor() { // Add to queue for reliability synchronized(messageQueueLock) { - messageQueue.add(Pair(event, targetRelays)) + if (messageQueue.size >= MAX_QUEUED_EVENTS) messageQueue.removeAt(0) + messageQueue.add(QueuedEvent(event, targetRelays.toMutableSet())) } // Attempt immediate send @@ -289,7 +316,9 @@ class NostrRelayManager private constructor() { targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { - sendToRelay(event, webSocket, relayUrl) + if (sendToRelay(event, webSocket, relayUrl)) { + markQueuedRelayDelivered(event.id, relayUrl) + } } } } @@ -303,14 +332,16 @@ class NostrRelayManager private constructor() { filter: NostrFilter, id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, - targetRelayUrls: List? = null + targetRelayUrls: List? = null, + owner: String = OWNER_LEGACY ): String { // Store subscription info for persistent tracking val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, handler = handler, - targetRelayUrls = targetRelayUrls?.toSet() + targetRelayUrls = targetRelayUrls?.toSet(), + owner = owner ) activeSubscriptions[id] = subscriptionInfo @@ -386,20 +417,28 @@ class NostrRelayManager private constructor() { } } } + + fun unsubscribeOwner(owner: String) { + activeSubscriptions.values + .filter { it.owner == owner } + .map { it.id } + .forEach(::unsubscribe) + } /** * Manually retry connection to a specific relay */ fun retryConnection(relayUrl: String) { val relay = relaysList.find { it.url == relayUrl } ?: return + desiredConnected.set(true) // Reset reconnection attempts relay.reconnectAttempts = 0 relay.nextReconnectTime = null // Disconnect if connected - connections[relayUrl]?.close(1000, "Manual retry") - connections.remove(relayUrl) + reconnectJobs.remove(relayUrl)?.cancel() + connections.remove(relayUrl)?.close(1000, "Manual retry") // Attempt immediate reconnection scope.launch { @@ -412,6 +451,7 @@ class NostrRelayManager private constructor() { * This will automatically restore all subscriptions when reconnected */ fun resetAllConnections() { + val shouldReconnect = desiredConnected.get() disconnect() // Reset all relay states @@ -421,8 +461,8 @@ class NostrRelayManager private constructor() { relay.lastError = null } - // Reconnect - subscriptions will be automatically restored in onOpen - connect() + // Reconnect only when connectivity was desired before the controlled reset. + if (shouldReconnect) connect() } /** @@ -546,8 +586,12 @@ class NostrRelayManager private constructor() { stopSubscriptionValidation() // Stop any existing validation subscriptionValidationJob = scope.launch { - while (isActive) { - delay(SUBSCRIPTION_VALIDATION_INTERVAL) + while (isActive && desiredConnected.get()) { + val validationInterval = powerManager?.profile?.value + ?.nostr?.subscriptionValidationMs + ?: com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS + delay(validationInterval) + if (!desiredConnected.get()) break try { val report = validateSubscriptionConsistency() @@ -587,6 +631,7 @@ class NostrRelayManager private constructor() { // MARK: - Private Methods private suspend fun connectToRelay(urlString: String) { + if (!desiredConnected.get()) return // Skip if we already have a connection if (connections.containsKey(urlString)) { return @@ -598,16 +643,22 @@ class NostrRelayManager private constructor() { .build() val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString)) - connections[urlString] = webSocket + val existing = connections.putIfAbsent(urlString, webSocket) + if (existing != null) { + webSocket.close(1000, "Duplicate connection") + } else if (!desiredConnected.get()) { + connections.remove(urlString, webSocket) + webSocket.close(1000, "Connection no longer desired") + } } catch (e: Exception) { Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}") - handleDisconnection(urlString, e) + handleConnectionCreationFailure(urlString, e) } } - private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) { - try { + private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean { + return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) @@ -617,11 +668,26 @@ class NostrRelayManager private constructor() { val relay = relaysList.find { it.url == relayUrl } relay?.messagesSent = (relay?.messagesSent ?: 0) + 1 updateRelaysList() + true } else { Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed") + false } } catch (e: Exception) { Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}") + false + } + } + + private fun markQueuedRelayDelivered(eventId: String, relayUrl: String) { + synchronized(messageQueueLock) { + val iterator = messageQueue.iterator() + while (iterator.hasNext()) { + val queued = iterator.next() + if (queued.event.id != eventId) continue + queued.pendingRelayUrls.remove(relayUrl) + if (queued.pendingRelayUrls.isEmpty()) iterator.remove() + } } } @@ -690,12 +756,22 @@ class NostrRelayManager private constructor() { } } - private fun handleDisconnection(relayUrl: String, error: Throwable) { - connections.remove(relayUrl) - // NOTE: Don't remove subscriptions here - keep them for restoration on reconnection - // subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss - + private fun handleDisconnection(relayUrl: String, webSocket: WebSocket, error: Throwable) { + // A close/failure from an intentionally closed or replaced socket is stale. In particular, + // it must not remove a newer socket or schedule a reconnect after manual disconnect. + if (!connections.remove(relayUrl, webSocket)) return + subscriptions.remove(relayUrl) + handleCurrentDisconnection(relayUrl, error) + } + + private fun handleConnectionCreationFailure(relayUrl: String, error: Throwable) { + if (!desiredConnected.get()) return + handleCurrentDisconnection(relayUrl, error) + } + + private fun handleCurrentDisconnection(relayUrl: String, error: Throwable) { updateRelayStatus(relayUrl, false, error) + if (!desiredConnected.get()) return // Check if this is a DNS error val errorMessage = error.message?.lowercase() ?: "" @@ -730,11 +806,13 @@ class NostrRelayManager private constructor() { Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})") - // Schedule reconnection - scope.launch { + reconnectJobs.remove(relayUrl)?.cancel() + val reconnectJob = scope.launch { delay(backoffInterval) - connectToRelay(relayUrl) + if (desiredConnected.get()) connectToRelay(relayUrl) } + reconnectJobs[relayUrl] = reconnectJob + reconnectJob.invokeOnCompletion { reconnectJobs.remove(relayUrl, reconnectJob) } } private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) { @@ -808,25 +886,32 @@ class NostrRelayManager private constructor() { private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { + if (!desiredConnected.get() || connections[relayUrl] !== webSocket) { + webSocket.close(1000, "Stale connection") + return + } + reconnectJobs.remove(relayUrl)?.cancel() Log.i(TAG, "Connected to Nostr relay: $relayUrl") updateRelayStatus(relayUrl, true) // Restore all active subscriptions for this relay restoreSubscriptionsForRelay(relayUrl, webSocket) - // Process any queued messages for this relay - synchronized(messageQueueLock) { - val iterator = messageQueue.iterator() - while (iterator.hasNext()) { - val (event, targetRelays) = iterator.next() - if (relayUrl in targetRelays) { - sendToRelay(event, webSocket, relayUrl) - } + // Process only events still pending for this relay, outside the queue lock. + val queuedForRelay = synchronized(messageQueueLock) { + messageQueue + .filter { relayUrl in it.pendingRelayUrls } + .map { it.event } + } + queuedForRelay.forEach { event -> + if (sendToRelay(event, webSocket, relayUrl)) { + markQueuedRelayDelivered(event.id, relayUrl) } } } override fun onMessage(webSocket: WebSocket, text: String) { + if (connections[relayUrl] !== webSocket) return handleMessage(text, relayUrl) } @@ -837,12 +922,12 @@ class NostrRelayManager private constructor() { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.i(TAG, "Disconnected from Nostr relay $relayUrl: $code $reason") val error = Exception("WebSocket closed: $code $reason") - handleDisconnection(relayUrl, error) + handleDisconnection(relayUrl, webSocket, error) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}") - handleDisconnection(relayUrl, t) + handleDisconnection(relayUrl, webSocket, t) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 1a8514ab..601e885c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -11,7 +11,8 @@ import kotlinx.coroutines.launch */ class NostrSubscriptionManager( private val application: Application, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val owner: String = NostrRelayManager.OWNER_LEGACY ) { companion object { private const val TAG = "NostrSubscriptionManager" } @@ -23,7 +24,7 @@ class NostrSubscriptionManager( fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) - relayManager.subscribe(filter, id, handler) + relayManager.subscribe(filter, id, handler, owner = owner) } } @@ -31,7 +32,15 @@ class NostrSubscriptionManager( fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) - relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner + ) } } @@ -39,9 +48,21 @@ class NostrSubscriptionManager( fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) - relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner + ) } } fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } + + fun unsubscribeAllOwned() { + scope.launch { runCatching { relayManager.unsubscribeOwner(owner) } } + } } diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt index f4ca9f74..30a641d0 100644 --- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -56,6 +56,8 @@ object AppShutdownCoordinator { // Stop mesh (best-effort) try { mesh?.stopServices() } catch (_: Exception) { } + try { com.bitchat.android.nostr.NostrRelayManager.shared.disconnect() } catch (_: Exception) { } + try { com.bitchat.android.mesh.PowerManager.getInstance(app).shutdown() } catch (_: Exception) { } // Stop Tor temporarily (do not change user setting) val torProvider = ArtiTorManager.getInstance() diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index d9ad2d7f..1286988f 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -19,8 +19,8 @@ import com.bitchat.android.mesh.BluetoothMeshService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch class MeshForegroundService : Service() { @@ -115,6 +115,7 @@ class MeshForegroundService : Service() { private val scope = CoroutineScope(Dispatchers.Default + serviceJob) private var isInForeground: Boolean = false private var isShuttingDown: Boolean = false + private var lastNotifiedPeerCount: Int? = null override fun onCreate() { super.onCreate() @@ -131,6 +132,16 @@ class MeshForegroundService : Service() { MeshServiceHolder.attach(created) } MeshServiceHolder.getUnifiedOrCreate(applicationContext) + + // Notification content is driven by peer-state changes, not a permanent timer. + updateJob = scope.launch { + com.bitchat.android.services.AppStateStore.peers + .map { peers -> peers.distinct().size } + .distinctUntilChanged() + .collect { + if (isInForeground) updateNotification(force = false) + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -175,9 +186,11 @@ class MeshForegroundService : Service() { ACTION_UPDATE_NOTIFICATION -> { // If we became eligible and are not in foreground yet, promote once if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val n = buildNotification(getUnifiedActivePeerCount()) + val count = getUnifiedActivePeerCount() + val n = buildNotification(count) startForegroundCompat(n) isInForeground = true + lastNotifiedPeerCount = count } else { updateNotification(force = true) } @@ -190,32 +203,11 @@ class MeshForegroundService : Service() { // Promote exactly once when eligible, otherwise stay background (or stop) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { - val notification = buildNotification(getUnifiedActivePeerCount()) + val count = getUnifiedActivePeerCount() + val notification = buildNotification(count) startForegroundCompat(notification) isInForeground = true - } - - // Periodically refresh the notification with live network size - if (updateJob == null) { - updateJob = scope.launch { - while (isActive) { - // Retry enabling mesh/foreground once permissions become available - ensureMeshStarted() - val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() - if (eligible) { - // Only update the notification; do not re-call startForeground() - updateNotification(force = false) - } else { - // If disabled or perms missing, ensure we are not in foreground and clear notif - if (isInForeground) { - try { stopForeground(false) } catch (_: Exception) { } - isInForeground = false - } - notificationManager.cancel(NOTIFICATION_ID) - } - delay(5000) - } - } + lastNotifiedPeerCount = count } return START_STICKY @@ -229,19 +221,9 @@ class MeshForegroundService : Service() { android.util.Log.e("MeshForegroundService", "Failed to ensure Wi-Fi Aware transport: ${e.message}") } - val bleEnabled = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) - } catch (_: Exception) { - true - } - if (!bleEnabled) { - try { meshService?.setBleTransportEnabled(false) } catch (_: Exception) { } - return - } - if (!hasBluetoothPermissions()) return try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") - val service = MeshServiceHolder.getOrCreate(applicationContext) + val service = MeshServiceHolder.getUnifiedOrCreate(applicationContext) service.startServices() } catch (e: Exception) { android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}") @@ -254,14 +236,17 @@ class MeshForegroundService : Service() { return } val count = getUnifiedActivePeerCount() - val notification = buildNotification(count) if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { - notificationManager.notify(NOTIFICATION_ID, notification) + if (lastNotifiedPeerCount != count) { + notificationManager.notify(NOTIFICATION_ID, buildNotification(count)) + lastNotifiedPeerCount = count + } } else if (force) { // If disabled and forced, make sure to remove any prior foreground state try { stopForeground(false) } catch (_: Exception) { } notificationManager.cancel(NOTIFICATION_ID) isInForeground = false + lastNotifiedPeerCount = null } } diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c7971eb8..c88e1bc5 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -17,6 +17,8 @@ object AppStateStore { private val peerIdsByTransport = mutableMapOf>() // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set. private val directPeerIdsByTransport = mutableMapOf>() + private val _directPeers = MutableStateFlow>(emptySet()) + val directPeers: StateFlow> = _directPeers.asStateFlow() // Connected peer IDs (mesh ephemeral IDs) private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers.asStateFlow() @@ -69,20 +71,25 @@ object AppStateStore { fun setTransportDirectPeers(transportId: String, ids: Collection) { synchronized(this) { directPeerIdsByTransport[transportId] = ids.toSet() + publishDirectPeersLocked() } } fun clearTransportDirectPeers(transportId: String) { synchronized(this) { directPeerIdsByTransport.remove(transportId) + publishDirectPeersLocked() } } /** Union of direct peers across all transports. */ - fun getDirectPeers(): Set { - synchronized(this) { - return directPeerIdsByTransport.values.flatten().toSet() - } + fun getDirectPeers(): Set = _directPeers.value + + private fun publishDirectPeersLocked() { + _directPeers.value = directPeerIdsByTransport.values + .asSequence() + .flatten() + .toSet() } fun addPublicMessage(msg: BitchatMessage) { @@ -210,6 +217,7 @@ object AppStateStore { peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() + _directPeers.value = emptySet() _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4c6d5cdc..87e6104e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -340,6 +340,7 @@ class ChatViewModel( } override fun onCleared() { + geohashViewModel.shutdownUiSubscriptions() super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } @@ -358,34 +359,9 @@ class ChatViewModel( */ private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) { try { - val repoField = GeohashViewModel::class.java.getDeclaredField("repo") - repoField.isAccessible = true - val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository - val gh = repo.getConversationGeohash(convKey) + val gh = geohashViewModel.conversationGeohash(convKey) if (!gh.isNullOrEmpty()) { - val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager") - subMgrField.isAccessible = true - val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager - val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication()) - val subId = "geo-dm-$gh" - val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId") - currentDmSubField.isAccessible = true - val currentId = currentDmSubField.get(geohashViewModel) as String? - if (currentId != subId) { - (currentId)?.let { subMgr.unsubscribe(it) } - currentDmSubField.set(geohashViewModel, subId) - subMgr.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = subId, - handler = { event -> - val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler") - dmHandlerField.isAccessible = true - val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler - dmHandler.onGiftWrap(event, gh, identity) - } - ) - } + com.bitchat.android.nostr.NostrBackgroundRuntime.ensureConversationDm(gh) } } catch (e: Exception) { Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index d94e5fff..2e74f3e0 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.NostrDirectMessageHandler +import com.bitchat.android.nostr.NostrBackgroundRuntime import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager @@ -23,11 +24,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.util.Date -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.Dispatchers -import java.security.SecureRandom -import kotlin.random.asKotlinRandom class GeohashViewModel( application: Application, @@ -39,19 +37,21 @@ class GeohashViewModel( private val notificationManager: NotificationManager ) : AndroidViewModel(application), DefaultLifecycleObserver { - companion object { - private const val TAG = "GeohashViewModel" - private val secureRandom = SecureRandom().asKotlinRandom() - } + companion object { private const val TAG = "GeohashViewModel" } private val repo = GeohashRepository(application, state, dataManager) - private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope) + private val uiSubscriptionOwner = "geohash-ui-${System.identityHashCode(this)}" + private val subscriptionManager = NostrSubscriptionManager( + application, + viewModelScope, + owner = uiSubscriptionOwner + ) private val geohashMessageHandler = GeohashMessageHandler( application = application, state = state, messageManager = messageManager, repo = repo, - scope = viewModelScope, + scope = NostrBackgroundRuntime.eventScope, dataManager = dataManager ) private val dmHandler = NostrDirectMessageHandler( @@ -59,18 +59,14 @@ class GeohashViewModel( state = state, privateChatManager = privateChatManager, meshDelegateHandler = meshDelegateHandler, - scope = viewModelScope, + scope = NostrBackgroundRuntime.eventScope, repo = repo, dataManager = dataManager ) - // Live channel message stream (kind 20000). Low-volume; kept alive in the background. - private var currentGeohashMsgSubId: String? = null // Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded. private var currentGeohashPresenceSubId: String? = null - private var currentDmSubId: String? = null private var geoTimer: Job? = null - private var globalPresenceJob: Job? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null private val activeSamplingGeohashes = mutableSetOf() @@ -82,23 +78,21 @@ class GeohashViewModel( val selectedLocationChannel: StateFlow = state.selectedLocationChannel fun initialize() { - subscriptionManager.connect() // Observe process lifecycle to manage background sampling kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.addObserver(this) } - val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) - if (identity != null) { - // Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below. - subscriptionManager.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = "chat-messages", - handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity) - ) - } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + NostrBackgroundRuntime.attachHandlers( + NostrBackgroundRuntime.Handlers( + accountDm = { event, identity -> dmHandler.onGiftWrap(event, "", identity) }, + geohashMessage = { event, geohash -> geohashMessageHandler.onEvent(event, geohash) }, + geohashDm = { event, geohash, identity -> + dmHandler.onGiftWrap(event, geohash, identity) + } + ) + ) viewModelScope.launch { locationChannelManager?.selectedChannel?.collect { channel -> state.setSelectedLocationChannel(channel) @@ -110,10 +104,6 @@ class GeohashViewModel( state.setIsTeleported(teleported) } } - - // Start global presence heartbeat loop - startGlobalPresenceHeartbeat() - } catch (e: Exception) { Log.e(TAG, "Failed to initialize location channel state: ${e.message}") state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) @@ -121,77 +111,17 @@ class GeohashViewModel( } } - private fun startGlobalPresenceHeartbeat() { - globalPresenceJob?.cancel() - globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - // Reactively restart heartbeat whenever available channels change - locationChannelManager?.availableChannels?.collectLatest { channels -> - // Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5 - val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash } - - if (targetGeohashes.isNotEmpty()) { - // Enter heartbeat loop for this set of channels - // If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately - while (true) { - // Randomize loop interval (40-80s, average 60s) - val loopInterval = secureRandom.nextLong(40000L, 80000L) - var timeSpent = 0L - - try { - Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels") - targetGeohashes.forEach { geohash -> - // Decorrelate individual broadcasts with random delay (1s-5s) - val stepDelay = secureRandom.nextLong(1000L, 10000L) - delay(stepDelay) - timeSpent += stepDelay - - broadcastPresence(geohash) - } - } catch (e: Exception) { - Log.w(TAG, "Global presence heartbeat error: ${e.message}") - } - - // Wait remaining time to satisfy target average cadence - val remaining = loopInterval - timeSpent - if (remaining > 0) { - delay(remaining) - } else { - delay(10000L) // Minimum guard delay - } - } - } - } - } - } - fun panicReset() { repo.clearAll() GeohashAliasRegistry.clear() GeohashConversationRegistry.clear() - subscriptionManager.disconnect() - currentGeohashMsgSubId = null + subscriptionManager.unsubscribeAllOwned() currentGeohashPresenceSubId = null - currentDmSubId = null activeChannelGeohash = null geoTimer?.cancel() geoTimer = null - globalPresenceJob?.cancel() - globalPresenceJob = null try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} - initialize() - } - - private suspend fun broadcastPresence(geohash: String) { - try { - val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) - val relayManager = NostrRelayManager.getInstance(getApplication()) - // Presence is lightweight, send to geohash relays - relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5) - Log.v(TAG, "💓 Sent presence heartbeat for $geohash") - } catch (e: Exception) { - Log.w(TAG, "Failed to send presence for $geohash: ${e.message}") - } + NostrBackgroundRuntime.resetSubscriptions() } fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { @@ -331,15 +261,14 @@ class GeohashViewModel( fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + fun conversationGeohash(conversationKey: String): String? = repo.getConversationGeohash(conversationKey) fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = nostrPeerColorSeed(pubkeyHex) private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null - currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null } currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } - currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null } when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { @@ -368,17 +297,11 @@ class GeohashViewModel( startGeoParticipantsTimer() - // Chat message stream (kind 20000) is low-volume; keep it alive even when - // backgrounded so geohash messages still arrive. - subscribeChannelMessages(channel.channel.geohash) // Presence heartbeat firehose (kind 20001) is the high-volume data hog; only // run it in the foreground. It is restored in onStart() and torn down in onStop(). if (isAppInForeground()) { subscribeChannelPresence(channel.channel.geohash) } - // Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is - // kept alive in the background so geohash DMs still arrive. - subscribeChannelDM(channel.channel.geohash) } null -> { Log.d(TAG, "📡 No channel selected") @@ -388,21 +311,6 @@ class GeohashViewModel( } } - /** - * Subscribe to the chat message stream (kind 20000) for a geohash channel. - * Low-volume; kept alive in the background so messages keep arriving. - */ - private fun subscribeChannelMessages(geohash: String) { - val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId - subscriptionManager.subscribeGeohashMessages( - geohash = geohash, - sinceMs = System.currentTimeMillis() - 3600000L, - limit = 200, - id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } - ) - } - /** * Subscribe to the presence heartbeat firehose (kind 20001) for a geohash channel. * High-volume; only used to refresh the participant list, so it is torn down in @@ -419,25 +327,6 @@ class GeohashViewModel( ) } - /** - * Subscribe to gift-wrap DMs for a geohash channel's derived identity. - * Lightweight (filtered to our pubkey); kept alive in the background. - */ - private fun subscribeChannelDM(geohash: String) { - viewModelScope.launch { - val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId - subscriptionManager.subscribeGiftWraps( - pubkey = dmIdentity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = dmSubId, - handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } - ) - // Also register alias in global registry for routing convenience - GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) - } - } - private fun startGeoParticipantsTimer() { geoTimer = viewModelScope.launch { while (repo.getCurrentGeohash() != null) { @@ -449,6 +338,11 @@ class GeohashViewModel( override fun onCleared() { super.onCleared() + shutdownUiSubscriptions() + } + + fun shutdownUiSubscriptions() { + subscriptionManager.unsubscribeAllOwned() kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } @@ -465,10 +359,6 @@ class GeohashViewModel( if (repo.getCurrentGeohash() != null && geoTimer?.isActive != true) { startGeoParticipantsTimer() } - // Resume the global presence heartbeat - if (globalPresenceJob?.isActive != true) { - startGlobalPresenceHeartbeat() - } } override fun onStop(owner: LifecycleOwner) { @@ -478,12 +368,9 @@ class GeohashViewModel( currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } // Drop geohash sampling subscriptions activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") } - // Stop broadcasting presence heartbeats - globalPresenceJob?.cancel(); globalPresenceJob = null // Stop participant-refresh polling geoTimer?.cancel(); geoTimer = null - // NOTE: gift-wrap DM subscriptions (per-geohash + global "chat-messages") are intentionally - // left active so direct messages still arrive while backgrounded. + // Process-owned message and DM subscriptions remain active. } private fun performSubscribeSampling(geohash: String) { diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index 4905c927..2be47a99 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -90,7 +90,7 @@ object AppConstants { const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L const val MAX_CONNECTIONS_NORMAL: Int = 8 const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 - const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 + const val MAX_CONNECTIONS_ULTRA_LOW: Int = 8 } object Nostr { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt index c3039327..9688f04f 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt @@ -24,10 +24,9 @@ class SyncedSocket( private val outputStream: DataOutputStream companion object { - // Both peers exchange keep-alive frames every ~2s while connected, so a read that - // stalls well beyond that means the link is dead (half-open). Time out so the read - // loop can detect it and trigger disconnection instead of blocking forever. - const val DEFAULT_READ_TIMEOUT_MS = 15_000 + // Critical-background peers send at most every 30s. Tolerate three missed frames so a + // faster local profile cannot drop a healthy, slower remote peer merely for being idle. + const val DEFAULT_READ_TIMEOUT_MS = 90_000 } init { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt index f2ec4cfa..1c2b7e5f 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt @@ -6,12 +6,10 @@ import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean @@ -68,24 +66,20 @@ object WifiAwareController { Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}") } setEnabled(enabledByDefault) - // Start background poller for debug surfacing - scope.launch { - while (isActive) { - try { - val s = service - if (s != null) { - _connectedPeers.value = s.getDeviceAddressToPeerMapping() // peerID -> ip - _knownPeers.value = s.getPeerNicknames() - _discoveredPeers.value = s.getDiscoveredPeerIds() - } else { - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() - } - } catch (_: Exception) { } - delay(1000) - } - } + } + + internal fun publishDebugSnapshot( + connected: Map, + known: Map, + discovered: Set + ) { + _connectedPeers.value = connected + _knownPeers.value = known + _discoveredPeers.value = discovered + } + + private fun clearDebugSnapshot() { + publishDebugSnapshot(emptyMap(), emptyMap(), emptySet()) } fun setEnabled(value: Boolean) { @@ -202,9 +196,7 @@ object WifiAwareController { } try { stopped?.stopServices() } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() + clearDebugSnapshot() try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {} } @@ -214,9 +206,7 @@ object WifiAwareController { service = null _running.value = false try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { } - _connectedPeers.value = emptyMap() - _knownPeers.value = emptyMap() - _discoveredPeers.value = emptySet() + clearDebugSnapshot() } } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index d6cbf981..cd83e898 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -76,10 +76,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor private const val CLIENT_SOCKET_ATTEMPTS = 3 private const val CLIENT_ROLE_REVERSAL_FAILURES = 3 private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L - // Discovery freshness window for reconnection maintenance - private const val DISCOVERY_STALE_MS = 5L * 60 * 1000 - private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000 - private const val DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS = 90L * 1000 private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:" } @@ -89,6 +85,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes) override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val powerManager = com.bitchat.android.mesh.PowerManager.getInstance(context.applicationContext) private val wifiTransport = WifiAwareTransport() private lateinit var meshCore: MeshCore private lateinit var fragmentingSender: FragmentingPacketSender @@ -175,6 +172,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor hooks = MeshCore.Hooks( onMessageReceived = { message -> handleMessageReceived(message) }, onAnnounceProcessed = { routed, _ -> + publishControllerDebugSnapshot() routed.peerID?.let { pid -> try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { } @@ -653,6 +651,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val now = System.currentTimeMillis() discoveredTimestamps[peerId] = now lastDiscoveryActivityAt.set(now) + publishControllerDebugSnapshot() } @RequiresApi(Build.VERSION_CODES.Q) @@ -669,7 +668,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) return false val lastRefresh = lastDiscoveryRefreshAt.get() - if ((now - lastRefresh) < DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS) return false + val minRefreshMs = powerManager.profile.value.wifiAware.discoverySessionRefreshMinMs + if ((now - lastRefresh) < minRefreshMs) return false if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false Log.i(TAG, "Refreshing Wi-Fi Aware discovery sessions ($reason)") @@ -684,7 +684,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor serviceScope.launch { while (isActive) { try { - delay(15_000) // Check every 15 seconds + val schedule = powerManager.profile.value.wifiAware + delay(schedule.connectionMaintenanceMs) if (!isActive) break val now = System.currentTimeMillis() @@ -692,18 +693,19 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor // 0. Prune stale discovery entries. PeerHandles become invalid when the // discovery sessions restart, so we must not keep pinging old handles forever. val staleIds = discoveredTimestamps.filter { (id, ts) -> - (now - ts) >= DISCOVERY_STALE_MS && !connectionTracker.isConnected(id) + (now - ts) >= schedule.discoveryStaleMs && !connectionTracker.isConnected(id) }.keys.toSet() if (staleIds.isNotEmpty()) { staleIds.forEach { discoveredTimestamps.remove(it) } handleToPeerId.entries.removeIf { it.value in staleIds } staleIds.forEach { subscribeHandles.remove(it) } staleIds.forEach { publishHandles.remove(it) } + publishControllerDebugSnapshot() } // 1. Identify peers that are discovered (recently seen) but not currently connected val recentDiscovered = discoveredTimestamps.filter { (id, ts) -> - (now - ts) < DISCOVERY_STALE_MS // Seen in last 5 minutes + (now - ts) < schedule.discoveryStaleMs }.keys // 2. Filter out those who are already connected @@ -753,7 +755,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor disconnectedPeers.isNotEmpty() && missingUsableHandle && !attemptedReconnect -> { refreshDiscoverySessions("missing peer handle", now) } - recentDiscovered.isEmpty() && idleFor >= DISCOVERY_IDLE_REFRESH_MS -> { + recentDiscovered.isEmpty() && idleFor >= schedule.discoveryIdleRefreshMs -> { refreshDiscoverySessions("idle discovery", now) } } @@ -896,6 +898,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val synced = SyncedSocket(client) activeSocket = synced connectionTracker.onClientConnected(peerId, synced) + publishControllerDebugSnapshot() // We only ever accept a single data socket per server request. Close the // listening ServerSocket now so it can't block a future re-serve (its // presence makes hasOpenServerSocket() true for the life of the process) @@ -964,30 +967,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor pubSession: PublishDiscoverySession, peerHandle: PeerHandle ) { - // TCP keep-alive pings - serviceScope.launch { - try { - while (connectionTracker.isConnected(peerId)) { - // write empty byte array effectively sends [4 bytes length=0] which is our ping - try { - client.write(ByteArray(0)) - } catch (_: IOException) { - // The write side is dead. Don't just stop pinging: actively tear down so the - // half-open socket stops counting as "connected" and maintenance can retry. - handlePeerDisconnection(peerId, client) - break - } - delay(2_000) - } - } catch (_: Exception) {} - } - // Discovery keep-alive - serviceScope.launch { - var msgId = 0 - while (connectionTracker.isConnected(peerId)) { - try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } - delay(20_000) - } + startProfiledKeepAlive(client, peerId) { msgId -> + pubSession.sendMessage(peerHandle, msgId, ByteArray(0)) } } @@ -1155,6 +1136,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor val synced = SyncedSocket(sock) activeSocket = synced connectionTracker.onClientConnected(peerId, synced) + publishControllerDebugSnapshot() clientSocketFailures.remove(peerId) try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {} try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {} @@ -1199,28 +1181,50 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor peerId: String, peerHandle: PeerHandle ) { - // TCP keep-alive + startProfiledKeepAlive(sock, peerId) { msgId -> + subscribeSession?.sendMessage(peerHandle, msgId, ByteArray(0)) + } + } + + /** + * One coroutine per peer schedules both TCP and discovery deadlines. This avoids two + * independently waking jobs while still adapting after every send to the latest profile. + */ + private fun startProfiledKeepAlive( + socket: SyncedSocket, + peerId: String, + sendDiscovery: (Int) -> Unit + ) { serviceScope.launch { - try { - while (connectionTracker.isConnected(peerId)) { + var discoveryMessageId = 0 + var nextTcpAt = 0L + var nextDiscoveryAt = 0L + while (connectionTracker.isConnected(peerId)) { + val now = android.os.SystemClock.elapsedRealtime() + val schedule = powerManager.profile.value.wifiAware + + if (now >= nextTcpAt) { try { - sock.write(ByteArray(0)) + socket.write(ByteArray(0)) } catch (_: IOException) { - // The write side is dead. Tear down so the half-open socket stops counting - // as "connected" and maintenance can retry instead of silently stalling. - handlePeerDisconnection(peerId, sock) + handlePeerDisconnection(peerId, socket) break } - delay(2_000) + nextTcpAt = now + schedule.tcpKeepAliveMs } - } catch (_: Exception) {} - } - // Discovery keep-alive - serviceScope.launch { - var msgId = 0 - while (connectionTracker.isConnected(peerId)) { - try { subscribeSession?.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break } - delay(20_000) + + if (now >= nextDiscoveryAt) { + try { + sendDiscovery(discoveryMessageId++) + } catch (_: Exception) { + // The TCP data path remains usable; discovery maintenance will recover its + // session independently without dropping this idle connection. + } + nextDiscoveryAt = now + schedule.discoveryKeepAliveMs + } + + val nextWakeAt = minOf(nextTcpAt, nextDiscoveryAt) + delay((nextWakeAt - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(250L)) } } } @@ -1297,6 +1301,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { } try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { } try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { } + publishControllerDebugSnapshot() Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}") } @@ -1401,6 +1406,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor } } // Else: socket replaced or inactive; do not remove peer/session, as a new socket has likely taken over + publishControllerDebugSnapshot() } } @@ -1611,6 +1617,16 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor fun getDiscoveredPeerIds(): Set = (handleToPeerId.values + discoveredTimestamps.keys).filter { it.isNotBlank() }.toSet() + private fun publishControllerDebugSnapshot() { + try { + com.bitchat.android.wifiaware.WifiAwareController.publishDebugSnapshot( + connected = getDeviceAddressToPeerMapping(), + known = getPeerNicknames(), + discovered = getDiscoveredPeerIds() + ) + } catch (_: Exception) { } + } + /** * Utility for logs/UI: pretty-prints one peer-to-address mapping per line. */ diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt new file mode 100644 index 00000000..8d4df343 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt @@ -0,0 +1,102 @@ +package com.bitchat.android.mesh + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PowerProfileResolverTest { + + @Test + fun `background normal without peers scans one second per minute`() { + val profile = resolve(battery = 80, background = true, peers = false) + + assertEquals(PowerManager.BatteryBand.NORMAL, profile.batteryBand) + assertEquals(1_000L, profile.ble.scanOnMs) + assertEquals(59_000L, profile.ble.scanOffMs) + assertFalse(profile.ble.continuousScan) + assertEquals(8, profile.ble.maxConnections) + assertEquals(60_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `any direct transport peer accelerates background BLE discovery to thirty seconds`() { + val profile = resolve(battery = 80, background = true, peers = true) + + assertEquals(1_000L, profile.ble.scanOnMs) + assertEquals(29_000L, profile.ble.scanOffMs) + } + + @Test + fun `background low profile uses selected wifi and nostr cadence`() { + val profile = resolve(battery = 20, background = true) + + assertEquals(PowerManager.BatteryBand.LOW, profile.batteryBand) + assertEquals(20_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(120_000L, profile.wifiAware.discoveryKeepAliveMs) + assertEquals(120_000L, profile.wifiAware.connectionMaintenanceMs) + assertEquals(600_000L, profile.wifiAware.discoveryIdleRefreshMs) + assertEquals(900_000L, profile.wifiAware.discoveryStaleMs) + assertEquals(600_000L, profile.nostr.presenceHeartbeatMinMs) + assertEquals(600_000L, profile.nostr.subscriptionValidationMs) + assertEquals(120_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `background critical profile uses slowest wifi and nostr cadence without reducing connections`() { + val profile = resolve(battery = 10, background = true, peers = true) + + assertEquals(PowerManager.BatteryBand.CRITICAL, profile.batteryBand) + assertEquals(30_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(180_000L, profile.wifiAware.discoveryKeepAliveMs) + assertEquals(180_000L, profile.wifiAware.connectionMaintenanceMs) + assertEquals(900_000L, profile.wifiAware.discoveryIdleRefreshMs) + assertEquals(1_200_000L, profile.wifiAware.discoveryStaleMs) + assertEquals(900_000L, profile.nostr.presenceHeartbeatMinMs) + assertEquals(900_000L, profile.nostr.subscriptionValidationMs) + assertEquals(300_000L, profile.meshAnnouncementIntervalMs) + assertEquals(8, profile.ble.maxConnections) + } + + @Test + fun `foreground normal preserves balanced BLE cadence`() { + val profile = resolve(battery = 80, background = false) + + assertEquals(PowerManager.PowerMode.BALANCED, profile.mode) + assertEquals(8_000L, profile.ble.scanOnMs) + assertEquals(2_000L, profile.ble.scanOffMs) + assertEquals(5_000L, profile.wifiAware.tcpKeepAliveMs) + assertEquals(30_000L, profile.meshAnnouncementIntervalMs) + } + + @Test + fun `foreground charging is performance but background charging stays power limited`() { + val foreground = PowerProfileResolver.resolve(80, true, false, false) + val background = PowerProfileResolver.resolve(80, true, true, false) + + assertEquals(PowerManager.PowerMode.PERFORMANCE, foreground.mode) + assertTrue(foreground.ble.continuousScan) + assertEquals(PowerManager.PowerMode.POWER_SAVER, background.mode) + assertFalse(background.ble.continuousScan) + assertEquals(59_000L, background.ble.scanOffMs) + } + + @Test + fun `battery thresholds are inclusive`() { + assertEquals(PowerManager.BatteryBand.NORMAL, resolve(21, true).batteryBand) + assertEquals(PowerManager.BatteryBand.LOW, resolve(20, true).batteryBand) + assertEquals(PowerManager.BatteryBand.LOW, resolve(11, true).batteryBand) + assertEquals(PowerManager.BatteryBand.CRITICAL, resolve(10, true).batteryBand) + } + + private fun resolve( + battery: Int, + background: Boolean, + peers: Boolean = false + ) = PowerProfileResolver.resolve( + batteryLevel = battery, + isCharging = false, + isBackground = background, + hasDirectPeers = peers + ) +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt index 577823ac..bebae3eb 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt @@ -77,6 +77,10 @@ class AppStateStoreTest { setOf("ble-1", "wifi-1", "shared"), AppStateStore.getDirectPeers() ) + assertEquals( + setOf("ble-1", "wifi-1", "shared"), + AppStateStore.directPeers.value + ) } @Test From b22184940b7811a31a7ef7b19cf32eb15d0154f7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:00:10 +0200 Subject: [PATCH 35/37] Harden background relay lifecycle --- .../android/mesh/UnifiedMeshService.kt | 27 +-- .../android/nostr/NostrBackgroundRuntime.kt | 56 +++--- .../android/nostr/NostrPendingEventQueue.kt | 90 +++++++++ .../android/nostr/NostrRelayManager.kt | 180 +++++++++--------- .../android/nostr/NostrSubscriptionManager.kt | 110 +++++------ .../android/service/MeshForegroundService.kt | 3 +- .../bitchat/android/ui/GeohashViewModel.kt | 8 +- .../nostr/NostrPendingEventQueueTest.kt | 81 ++++++++ .../NostrRelayManagerLifecycleSmokeTest.kt | 28 +++ 9 files changed, 401 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index b9f84547..5a0cc697 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -6,7 +6,6 @@ import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.model.BitchatFilePacket import com.bitchat.android.model.BitchatMessage import com.bitchat.android.noise.NoiseSession -import com.bitchat.android.services.AppStateStore import com.bitchat.android.wifiaware.WifiAwareController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,7 +13,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -77,18 +77,21 @@ class UnifiedMeshService( private fun startAnnouncementScheduler() { if (announcementJob?.isActive == true) return announcementJob = serviceScope.launch { - combine(powerManager.profile, AppStateStore.directPeers) { profile, peers -> - profile.meshAnnouncementIntervalMs to peers.isNotEmpty() - }.collectLatest { (intervalMs, hasRecipients) -> - if (!hasRecipients) return@collectLatest - // Connection-specific paths already send an immediate announce. Begin the - // periodic cadence after the configured interval to avoid a transition burst. - while (isActive) { - delay(intervalMs) - if (AppStateStore.directPeers.value.isNotEmpty()) sendBroadcastAnnounce() + powerManager.profile + .map { profile -> + profile.meshAnnouncementIntervalMs to profile.hasDirectPeers + } + .distinctUntilChanged() + .collectLatest { (intervalMs, hasRecipients) -> + if (!hasRecipients) return@collectLatest + // Connection-specific paths already send an immediate announce. Begin the + // periodic cadence after the configured interval to avoid a transition burst. + while (isActive) { + delay(intervalMs) + if (powerManager.profile.value.hasDirectPeers) sendBroadcastAnnounce() + } } } - } } override fun sendMessage(content: String, mentions: List, channel: String?) { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt index 9e3bf785..3931ea72 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -61,7 +61,6 @@ object NostrBackgroundRuntime { locationChannels = LocationChannelManager.getInstance(app) subscriptions = NostrSubscriptionManager( app, - scope, owner = NostrRelayManager.OWNER_BACKGROUND ) } @@ -94,7 +93,13 @@ object NostrBackgroundRuntime { } fun ensureConversationDm(geohash: String) { - if (!initialized || geohash == activeGeohash || geohash == conversationGeohash) return + if (!initialized || geohash == conversationGeohash) return + val selectedLiveToken = activeGeohashLiveToken + val selectedChannelSubscriptionIsUsable = + geohash == activeGeohash && + (selectedLiveToken == null || + LiveLocationPrivacyGate.accepts(selectedLiveToken)) + if (selectedChannelSubscriptionIsUsable) return conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") } conversationGeohash = geohash subscribeGeohashDm( @@ -119,8 +124,9 @@ object NostrBackgroundRuntime { private fun observeSelectedChannel() { scope.launch { locationChannels.selectedChannel.collectLatest { channel -> - val next = (channel as? ChannelID.Location)?.channel?.geohash - val nextToken = (channel as? ChannelID.Location)?.let { + val locationChannel = channel as? ChannelID.Location + val next = locationChannel?.channel?.geohash + val nextToken = locationChannel?.let { locationChannels.liveLocationTokenForSelectedChannel(it.channel) } val previous = activeGeohash @@ -139,9 +145,8 @@ object NostrBackgroundRuntime { conversationGeohash = null } next?.let { geohash -> - val isLiveDerived = (channel as? ChannelID.Location)?.let { - locationChannels.isSelectedChannelLiveDerived(it.channel) - } == true + val isLiveDerived = + locationChannels.isSelectedChannelLiveDerived(locationChannel.channel) if (!isLiveDerived || nextToken != null) { subscribeSelectedGeohash(geohash, nextToken) } @@ -171,21 +176,28 @@ object NostrBackgroundRuntime { liveLocationToken: Long? ) { scope.launch { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return@launch - val identity = NostrIdentityBridge.deriveIdentity(geohash, application) - subscriptions.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172_800_000L, - id = subscriptionId, - handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } }, - liveLocationToken = liveLocationToken - ) - GeohashAliasRegistry.put( - "nostr_${identity.publicKeyHex.take(16)}", - identity.publicKeyHex - ) + val subscribe = { + val identity = NostrIdentityBridge.deriveIdentity(geohash, application) + subscriptions.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172_800_000L, + id = subscriptionId, + handler = { event -> + dispatch { it.geohashDm(event, geohash, identity) } + }, + liveLocationToken = liveLocationToken + ) + GeohashAliasRegistry.put( + "nostr_${identity.publicKeyHex.take(16)}", + identity.publicKeyHex + ) + } + + if (liveLocationToken == null) { + subscribe() + } else { + LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, subscribe) + } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt new file mode 100644 index 00000000..dd9e16d1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt @@ -0,0 +1,90 @@ +package com.bitchat.android.nostr + +/** + * Thread-safe bounded queue of relay deliveries awaiting a usable WebSocket. + * + * Queue entries have a local ID rather than using the Nostr event ID: the same signed event may be + * intentionally published more than once with different relay sets or privacy provenance. + */ +internal class NostrPendingEventQueue( + private val capacity: Int +) { + init { + require(capacity > 0) + } + + data class Delivery( + val queueId: Long, + val event: NostrEvent, + val liveLocationToken: Long? + ) + + private data class Entry( + val queueId: Long, + val event: NostrEvent, + val pendingRelayUrls: MutableSet, + val liveLocationToken: Long? + ) + + private val lock = Any() + private val entries = ArrayDeque() + private var nextQueueId = 1L + + fun enqueue( + event: NostrEvent, + relayUrls: Collection, + liveLocationToken: Long? + ): Long? { + val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() } + if (pendingRelays.isEmpty()) return null + + return synchronized(lock) { + if (entries.size >= capacity) entries.removeFirst() + val queueId = nextQueueId++ + entries.addLast( + Entry( + queueId = queueId, + event = event, + pendingRelayUrls = pendingRelays, + liveLocationToken = liveLocationToken + ) + ) + queueId + } + } + + fun pendingForRelay(relayUrl: String): List = synchronized(lock) { + entries + .asSequence() + .filter { relayUrl in it.pendingRelayUrls } + .map { Delivery(it.queueId, it.event, it.liveLocationToken) } + .toList() + } + + fun markDelivered(queueId: Long, relayUrl: String) { + synchronized(lock) { + val iterator = entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.queueId != queueId) continue + entry.pendingRelayUrls.remove(relayUrl) + if (entry.pendingRelayUrls.isEmpty()) iterator.remove() + return + } + } + } + + fun removeLiveLocationEvents() { + synchronized(lock) { + entries.removeAll { it.liveLocationToken != null } + } + } + + fun clear() { + synchronized(lock) { + entries.clear() + } + } + + internal fun size(): Int = synchronized(lock) { entries.size } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index d361ec49..3386a014 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -2,17 +2,17 @@ package com.bitchat.android.nostr import android.util.Log import com.bitchat.android.geohash.LiveLocationPrivacyGate -import com.google.gson.Gson import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import com.google.gson.JsonArray +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import com.google.gson.JsonParser import kotlinx.coroutines.* import okhttp3.* import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.min import kotlin.math.pow @@ -114,15 +114,8 @@ class NostrRelayManager private constructor() { // Event deduplication system private val eventDeduplicator = NostrEventDeduplicator.getInstance() - // Message queue for reliability - private data class QueuedEvent( - val event: NostrEvent, - val pendingRelayUrls: MutableSet, - val liveLocationToken: Long? = null - ) - - private val messageQueue = mutableListOf() - private val messageQueueLock = Any() + // Bounded per-relay delivery queue for reconnect reliability. + private val messageQueue = NostrPendingEventQueue(MAX_QUEUED_EVENTS) // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -287,14 +280,24 @@ class NostrRelayManager private constructor() { ) closeTargets.forEach { (relayUrl, relaySubscriptionIds) -> val webSocket = connections[relayUrl] ?: return@forEach - relaySubscriptionIds.forEach { subscriptionId -> + for (subscriptionId in relaySubscriptionIds) { val request = NostrRequest.Close(subscriptionId) val message = gson.toJson(request, NostrRequest::class.java) val closeQueued = runCatching { webSocket.send(message) } .getOrDefault(false) if (!closeQueued) { connections.remove(relayUrl, webSocket) + subscriptions.remove(relayUrl) webSocket.cancel() + updateRelayStatus( + relayUrl, + isConnected = false, + error = IllegalStateException("Failed to close revoked subscription") + ) + if (desiredConnected.get() && relayUrl in nonLiveRelayUrls) { + scope.launch { connectToRelay(relayUrl, liveLocationToken = null) } + } + break } } } @@ -314,9 +317,7 @@ class NostrRelayManager private constructor() { } subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds } - synchronized(messageQueueLock) { - messageQueue.removeAll { it.liveLocationToken != null } - } + messageQueue.removeLiveLocationEvents() liveGeohashTokens.keys.forEach(geohashToRelays::remove) liveGeohashTokens.clear() @@ -325,6 +326,8 @@ class NostrRelayManager private constructor() { .filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls } liveOnlyRelayUrls.forEach { relayUrl -> connections.remove(relayUrl)?.cancel() + subscriptions.remove(relayUrl) + reconnectJobs.remove(relayUrl)?.cancel() } synchronized(relaysList) { relaysList.removeAll { it.url in liveOnlyRelayUrls } @@ -451,28 +454,24 @@ class NostrRelayManager private constructor() { relayUrls: List? = null, liveLocationToken: Long? = null ) { - val targetRelays = relayUrls ?: relaysList.map { it.url } + val targetRelays = (relayUrls ?: relaysList.map { it.url }) + .filter { it.isNotBlank() } + .distinct() + if (targetRelays.isEmpty()) return val queued = runNetworkAction(liveLocationToken) { - synchronized(messageQueueLock) { - if (messageQueue.size >= MAX_QUEUED_EVENTS) { - messageQueue.removeAt(0) - } - messageQueue.add( - QueuedEvent( - event = event, - pendingRelayUrls = targetRelays.toMutableSet(), - liveLocationToken = liveLocationToken - ) - ) - } + val queueId = messageQueue.enqueue( + event = event, + relayUrls = targetRelays, + liveLocationToken = liveLocationToken + ) ?: return@runNetworkAction scope.launch { if (!isNetworkActionAllowed(liveLocationToken)) return@launch targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) { - markQueuedRelayDelivered(event.id, relayUrl) + messageQueue.markDelivered(queueId, relayUrl) } } } @@ -673,9 +672,7 @@ class NostrRelayManager private constructor() { geohashToRelays.clear() // Clear any queued messages waiting to be sent - synchronized(messageQueueLock) { - messageQueue.clear() - } + messageQueue.clear() Log.i(TAG, "Cleared all Nostr subscriptions and routing caches") } catch (e: Exception) { @@ -767,39 +764,52 @@ class NostrRelayManager private constructor() { stopSubscriptionValidation() // Stop any existing validation subscriptionValidationJob = scope.launch { - while (isActive && desiredConnected.get()) { - val validationInterval = powerManager?.profile?.value - ?.nostr?.subscriptionValidationMs - ?: com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS - delay(validationInterval) - if (!desiredConnected.get()) break - - try { - val report = validateSubscriptionConsistency() - if (!report.isConsistent && report.connectedRelayCount > 0) { - Log.w(TAG, "Nostr subscription inconsistencies detected") - - // Auto-repair: re-establish subscriptions for relays with missing ones - connections.forEach { (relayUrl, webSocket) -> - val currentSubs = subscriptions[relayUrl] ?: emptySet() - val expectedSubs = activeSubscriptions.keys.filter { subId -> - val subInfo = activeSubscriptions[subId] - subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl) - }.toSet() - - val missingSubs = expectedSubs - currentSubs - if (missingSubs.isNotEmpty()) { - Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions") - restoreSubscriptionsForRelay(relayUrl, webSocket) - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error during subscription validation: ${e.message}") + val manager = powerManager + if (manager == null) { + runSubscriptionValidationLoop( + com.bitchat.android.util.AppConstants.Nostr + .SUBSCRIPTION_VALIDATION_INTERVAL_MS + ) + return@launch + } + + manager.profile + .map { it.nostr.subscriptionValidationMs } + .distinctUntilChanged() + .collectLatest(::runSubscriptionValidationLoop) + } + } + + private suspend fun runSubscriptionValidationLoop(intervalMs: Long) { + while (currentCoroutineContext().isActive && desiredConnected.get()) { + delay(intervalMs) + if (!desiredConnected.get()) break + validateAndRepairSubscriptions() + } + } + + private fun validateAndRepairSubscriptions() { + try { + val report = validateSubscriptionConsistency() + if (report.isConsistent || report.connectedRelayCount == 0) return + + Log.w(TAG, "Nostr subscription inconsistencies detected") + connections.forEach { (relayUrl, webSocket) -> + val currentSubs = subscriptions[relayUrl] ?: emptySet() + val expectedSubs = activeSubscriptions.keys.filter { subId -> + val subInfo = activeSubscriptions[subId] + subInfo?.targetRelayUrls == null || + subInfo.targetRelayUrls.contains(relayUrl) + }.toSet() + + if ((expectedSubs - currentSubs).isNotEmpty()) { + Log.i(TAG, "Auto-repairing missing subscriptions") + restoreSubscriptionsForRelay(relayUrl, webSocket) } } + } catch (e: Exception) { + Log.e(TAG, "Error during subscription validation: ${e.message}") } - } /** @@ -869,8 +879,9 @@ class NostrRelayManager private constructor() { } if (success) { // Update relay stats - val relay = relaysList.find { it.url == relayUrl } - relay?.messagesSent = (relay?.messagesSent ?: 0) + 1 + relaysList.find { it.url == relayUrl }?.let { relay -> + relay.messagesSent += 1 + } updateRelaysList() true } else { @@ -883,18 +894,6 @@ class NostrRelayManager private constructor() { } } - private fun markQueuedRelayDelivered(eventId: String, relayUrl: String) { - synchronized(messageQueueLock) { - val iterator = messageQueue.iterator() - while (iterator.hasNext()) { - val queued = iterator.next() - if (queued.event.id != eventId) continue - queued.pendingRelayUrls.remove(relayUrl) - if (queued.pendingRelayUrls.isEmpty()) iterator.remove() - } - } - } - private fun handleMessage(message: String, relayUrl: String) { try { val jsonElement = JsonParser.parseString(message) @@ -908,8 +907,9 @@ class NostrRelayManager private constructor() { when (response) { is NostrResponse.Event -> { // Update relay stats - val relay = relaysList.find { it.url == relayUrl } - relay?.messagesReceived = (relay?.messagesReceived ?: 0) + 1 + relaysList.find { it.url == relayUrl }?.let { relay -> + relay.messagesReceived += 1 + } updateRelaysList() // CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter @@ -1142,17 +1142,17 @@ class NostrRelayManager private constructor() { restoreSubscriptionsForRelay(relayUrl, webSocket) // Process only events still pending for this relay, outside the queue lock. - val queuedForRelay = synchronized(messageQueueLock) { - messageQueue - .filter { - relayUrl in it.pendingRelayUrls && - isNetworkActionAllowed(it.liveLocationToken) - } - .map { it.event to it.liveLocationToken } - } - queuedForRelay.forEach { (event, token) -> - if (sendToRelay(event, webSocket, relayUrl, token)) { - markQueuedRelayDelivered(event.id, relayUrl) + val queuedForRelay = messageQueue.pendingForRelay(relayUrl) + .filter { isNetworkActionAllowed(it.liveLocationToken) } + queuedForRelay.forEach { delivery -> + if (sendToRelay( + delivery.event, + webSocket, + relayUrl, + delivery.liveLocationToken + ) + ) { + messageQueue.markDelivered(delivery.queueId, relayUrl) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index e378e31e..19ebeb9c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -3,24 +3,32 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.geohash.LiveLocationPrivacyGate -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch /** * NostrSubscriptionManager - * - Encapsulates subscription lifecycle with NostrRelayManager + * - Encapsulates ordered subscription lifecycle with NostrRelayManager. + * + * Relay-manager operations are already non-blocking and schedule network I/O on their own scope. + * Keeping this facade synchronous prevents lifecycle cancellation from dropping unsubscribe work + * and guarantees that a channel switch closes the old subscription before opening the new one. */ class NostrSubscriptionManager( private val application: Application, - private val scope: CoroutineScope, private val owner: String = NostrRelayManager.OWNER_LEGACY ) { companion object { private const val TAG = "NostrSubscriptionManager" } private val relayManager get() = NostrRelayManager.getInstance(application) - fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } } - fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } } + fun connect() { + runCatching { relayManager.connect() } + .onFailure { Log.e(TAG, "connect failed: ${it.message}") } + } + + fun disconnect() { + runCatching { relayManager.disconnect() } + .onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } + } fun subscribeGiftWraps( pubkey: String, @@ -29,19 +37,15 @@ class NostrSubscriptionManager( handler: (NostrEvent) -> Unit, liveLocationToken: Long? = null ) { - scope.launch { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return@launch - val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) - relayManager.subscribe( - filter = filter, - id = id, - handler = handler, - owner = owner, - liveLocationToken = liveLocationToken - ) - } + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) + relayManager.subscribe( + filter = filter, + id = id, + handler = handler, + owner = owner, + liveLocationToken = liveLocationToken + ) } /** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */ @@ -53,22 +57,18 @@ class NostrSubscriptionManager( handler: (NostrEvent) -> Unit, liveLocationToken: Long? = null ) { - scope.launch { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return@launch - val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) - relayManager.subscribeForGeohash( - geohash, - filter, - id, - handler, - includeDefaults = false, - nRelays = 5, - owner = owner, - liveLocationToken = liveLocationToken - ) - } + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner, + liveLocationToken = liveLocationToken + ) } /** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */ @@ -80,26 +80,28 @@ class NostrSubscriptionManager( handler: (NostrEvent) -> Unit, liveLocationToken: Long? = null ) { - scope.launch { - if (liveLocationToken != null && - !LiveLocationPrivacyGate.accepts(liveLocationToken) - ) return@launch - val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) - relayManager.subscribeForGeohash( - geohash, - filter, - id, - handler, - includeDefaults = false, - nRelays = 5, - owner = owner, - liveLocationToken = liveLocationToken - ) - } + if (!isAllowed(liveLocationToken)) return + val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + owner = owner, + liveLocationToken = liveLocationToken + ) } - fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } - fun unsubscribeAllOwned() { - scope.launch { runCatching { relayManager.unsubscribeOwner(owner) } } + fun unsubscribe(id: String) { + runCatching { relayManager.unsubscribe(id) } } + + fun unsubscribeAllOwned() { + runCatching { relayManager.unsubscribeOwner(owner) } + } + + private fun isAllowed(liveLocationToken: Long?): Boolean = + liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken) } diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index 1286988f..5443808a 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -112,7 +112,8 @@ class MeshForegroundService : Service() { private val unifiedMeshService: com.bitchat.android.mesh.MeshService? get() = MeshServiceHolder.unifiedMeshService private val serviceJob = Job() - private val scope = CoroutineScope(Dispatchers.Default + serviceJob) + // Service lifecycle callbacks and notification state are main-thread confined. + private val scope = CoroutineScope(Dispatchers.Main.immediate + serviceJob) private var isInForeground: Boolean = false private var isShuttingDown: Boolean = false private var lastNotifiedPeerCount: Int? = null diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index dbc312b5..46504649 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -42,10 +42,9 @@ class GeohashViewModel( companion object { private const val TAG = "GeohashViewModel" } private val repo = GeohashRepository(application, state, dataManager) - private val uiSubscriptionOwner = "geohash-ui-${System.identityHashCode(this)}" + private val uiSubscriptionOwner = "geohash-ui-${UUID.randomUUID()}" private val subscriptionManager = NostrSubscriptionManager( application, - viewModelScope, owner = uiSubscriptionOwner ) private val geohashMessageHandler = GeohashMessageHandler( @@ -75,6 +74,7 @@ class GeohashViewModel( private val liveSamplingSubscriptionGeohashes = mutableSetOf() private var requestedLiveSamplingGeohashes: Set = emptySet() private var requestedUserSamplingGeohashes: Set = emptySet() + private var uiSubscriptionsShutdown = false private val liveLocationRevocationListener: () -> Unit = { val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet() revokedLiveGeohashes.forEach { geohash -> @@ -424,11 +424,13 @@ class GeohashViewModel( } override fun onCleared() { - super.onCleared() shutdownUiSubscriptions() + super.onCleared() } fun shutdownUiSubscriptions() { + if (uiSubscriptionsShutdown) return + uiSubscriptionsShutdown = true subscriptionManager.unsubscribeAllOwned() geoTimer?.cancel() geoTimer = null diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt new file mode 100644 index 00000000..8954cbe3 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NostrPendingEventQueueTest { + @Test + fun `empty relay set is not queued`() { + val queue = NostrPendingEventQueue(capacity = 2) + + assertNull(queue.enqueue(event("empty"), emptyList(), liveLocationToken = null)) + assertEquals(0, queue.size()) + } + + @Test + fun `capacity evicts the oldest publish`() { + val queue = NostrPendingEventQueue(capacity = 2) + queue.enqueue(event("one"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("two"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("three"), listOf("relay"), liveLocationToken = null) + + assertEquals( + listOf("two", "three"), + queue.pendingForRelay("relay").map { it.event.content } + ) + } + + @Test + fun `duplicate event publishes retain independent delivery state`() { + val queue = NostrPendingEventQueue(capacity = 4) + val signedEvent = event("same") + val firstId = requireNotNull( + queue.enqueue(signedEvent, listOf("relay-a", "relay-b"), liveLocationToken = null) + ) + val secondId = requireNotNull( + queue.enqueue(signedEvent, listOf("relay-a"), liveLocationToken = null) + ) + assertNotEquals(firstId, secondId) + + queue.markDelivered(firstId, "relay-a") + + assertEquals( + listOf(secondId), + queue.pendingForRelay("relay-a").map { it.queueId } + ) + assertEquals( + listOf(firstId), + queue.pendingForRelay("relay-b").map { it.queueId } + ) + + queue.markDelivered(firstId, "relay-b") + assertEquals(1, queue.size()) + } + + @Test + fun `privacy purge retains non-live publishes`() { + val queue = NostrPendingEventQueue(capacity = 4) + queue.enqueue(event("manual"), listOf("relay"), liveLocationToken = null) + queue.enqueue(event("live"), listOf("relay"), liveLocationToken = 42L) + + queue.removeLiveLocationEvents() + + assertEquals( + listOf("manual"), + queue.pendingForRelay("relay").map { it.event.content } + ) + } + + private fun event(content: String): NostrEvent { + val privateKey = "0".repeat(63) + "1" + return NostrEvent( + pubkey = NostrCrypto.derivePublicKey(privateKey), + createdAt = 1, + kind = NostrKind.TEXT_NOTE, + tags = emptyList(), + content = content + ).sign(privateKey) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt index 7bb4aa30..de53a579 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt @@ -9,6 +9,34 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class NostrRelayManagerLifecycleSmokeTest { + @Test + fun `owner teardown is synchronous and preserves other subscription owners`() { + val manager = NostrRelayManager.shared + manager.disconnect() + manager.clearAllSubscriptions() + val filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)) + + manager.subscribe( + filter = filter, + id = "background-contract", + handler = {}, + targetRelayUrls = emptyList(), + owner = NostrRelayManager.OWNER_BACKGROUND + ) + manager.subscribe( + filter = filter, + id = "ui-contract", + handler = {}, + targetRelayUrls = emptyList(), + owner = "test-ui" + ) + + manager.unsubscribeOwner("test-ui") + + assertEquals(setOf("background-contract"), manager.getActiveSubscriptions().keys) + manager.clearAllSubscriptions() + } + @Test fun `disconnected manager maintains subscription and empty publish invariants locally`() { val manager = NostrRelayManager.shared From d8e380efae06f2d1ae7fcb063b0cc7cea989fb6c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:23:26 +0200 Subject: [PATCH 36/37] Fix background Nostr event processing --- .../android/nostr/GeohashMessageHandler.kt | 9 +- .../android/nostr/GeohashRepository.kt | 18 +++ .../nostr/NostrBackgroundEventProcessor.kt | 115 ++++++++++++++++++ .../android/nostr/NostrBackgroundRuntime.kt | 59 ++++----- .../nostr/NostrDirectMessageHandler.kt | 15 ++- .../bitchat/android/services/AppStateStore.kt | 16 +++ .../java/com/bitchat/android/ui/ChatState.kt | 2 + .../com/bitchat/android/ui/ChatViewModel.kt | 3 +- .../com/bitchat/android/ui/DataManager.kt | 6 + .../bitchat/android/ui/GeohashViewModel.kt | 53 ++++---- .../bitchat/android/ui/PrivateChatManager.kt | 10 +- .../NostrBackgroundEventProcessorTest.kt | 65 ++++++++++ .../android/ui/PrivateChatManagerTest.kt | 29 +++++ 13 files changed, 316 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 1fc802d9..88d41466 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -3,8 +3,6 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.ui.ChatState -import com.bitchat.android.ui.MessageManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Date @@ -17,11 +15,10 @@ import java.util.Date */ class GeohashMessageHandler( private val application: Application, - private val state: ChatState, - private val messageManager: MessageManager, private val repo: GeohashRepository, private val scope: CoroutineScope, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val addChannelMessage: (String, BitchatMessage) -> Unit ) { companion object { private const val TAG = "GeohashMessageHandler" } @@ -103,7 +100,7 @@ class GeohashMessageHandler( if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null } catch (_: Exception) { null } ) - messageManager.addChannelMessage("geo:$subscribedGeohash", msg) + addChannelMessage("geo:$subscribedGeohash", msg) } catch (e: Exception) { Log.e(TAG, "onEvent error: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index 3a7ced32..50c72cd4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -28,14 +28,17 @@ class GeohashRepository( // conversation key (e.g., "nostr_") -> source geohash it belongs to private val conversationGeohash: MutableMap = mutableMapOf() + @Synchronized fun setConversationGeohash(convKey: String, geohash: String) { if (geohash.isNotEmpty()) { conversationGeohash[convKey] = geohash } } + @Synchronized fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey] + @Synchronized fun findPubkeyByNickname(targetNickname: String): String? { return geoNicknames.entries.firstOrNull { (_, nickname) -> val base = nickname.split("#").firstOrNull() ?: nickname @@ -43,6 +46,7 @@ class GeohashRepository( }?.key } + @Synchronized fun findPubkeyByShortId(shortId: String): String? { // First check cached nicknames (fastest) var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) } @@ -66,6 +70,7 @@ class GeohashRepository( fun setCurrentGeohash(geo: String?) { currentGeohash = geo } fun getCurrentGeohash(): String? = currentGeohash + @Synchronized fun clearAll() { geohashParticipants.clear() geoNicknames.clear() @@ -76,6 +81,7 @@ class GeohashRepository( currentGeohash = null } + @Synchronized fun cacheNickname(pubkeyHex: String, nickname: String) { val lower = pubkeyHex.lowercase() val previous = geoNicknames[lower] @@ -85,8 +91,10 @@ class GeohashRepository( } } + @Synchronized fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()] + @Synchronized fun markTeleported(pubkeyHex: String) { val set = state.getTeleportedGeoValue().toMutableSet() val key = pubkeyHex.lowercase() @@ -97,10 +105,12 @@ class GeohashRepository( } } + @Synchronized fun isPersonTeleported(pubkeyHex: String): Boolean { return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) } + @Synchronized fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) { val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } // Cap to now: prevents future-timestamped events (clock skew / malicious created_at) @@ -117,6 +127,7 @@ class GeohashRepository( updateReactiveParticipantCounts() } + @Synchronized fun geohashParticipantCount(geohash: String): Int { val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val participants = geohashParticipants[geohash] ?: return 0 @@ -130,6 +141,7 @@ class GeohashRepository( return participants.keys.count { !dataManager.isGeohashUserBlocked(it) } } + @Synchronized fun refreshGeohashPeople() { val geohash = currentGeohash if (geohash == null) { @@ -168,6 +180,7 @@ class GeohashRepository( state.setGeohashPeople(people) } + @Synchronized fun updateReactiveParticipantCounts() { val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) val counts = mutableMapOf() @@ -180,12 +193,15 @@ class GeohashRepository( state.setGeohashParticipantCounts(counts) } + @Synchronized fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { nostrKeyMapping[tempKeyOrPeer] = pubkeyHex } + @Synchronized fun getNostrKeyMapping(): Map = nostrKeyMapping.toMap() + @Synchronized fun displayNameForNostrPubkey(pubkeyHex: String): String { val suffix = pubkeyHex.takeLast(4) val lower = pubkeyHex.lowercase() @@ -203,6 +219,7 @@ class GeohashRepository( return "$nick#$suffix" } + @Synchronized fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { val lower = pubkeyHex.lowercase() val suffix = pubkeyHex.takeLast(4) @@ -234,6 +251,7 @@ class GeohashRepository( /** * Get display name for any geohash (not just current one) for header titles */ + @Synchronized fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { val lower = pubkeyHex.lowercase() val suffix = pubkeyHex.takeLast(4) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt new file mode 100644 index 00000000..442840eb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt @@ -0,0 +1,115 @@ +package com.bitchat.android.nostr + +import android.app.Application +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import com.bitchat.android.ui.NoiseSessionDelegate +import com.bitchat.android.ui.PrivateChatManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Process-owned Nostr event processing. + * + * Relay subscriptions must remain useful when no Activity exists, so their handlers cannot be + * borrowed from a ViewModel. This processor owns only application-scoped collaborators and writes + * messages to [AppStateStore], which the next UI instance hydrates from. + */ +internal class NostrBackgroundEventProcessor( + application: Application, + parentScope: CoroutineScope +) { + private val scope = CoroutineScope( + parentScope.coroutineContext + Dispatchers.IO.limitedParallelism(1) + ) + private val state = ChatState(scope) + private val dataManager = DataManager(application.applicationContext).apply { + state.setNickname(loadNickname()) + loadBlockedUsers() + loadGeohashBlockedUsers() + } + private val messageManager = MessageManager(state) + private val geohashRepository = GeohashRepository(application, state, dataManager) + private val privateChatManager = PrivateChatManager( + state = state, + messageManager = messageManager, + dataManager = dataManager, + noiseSessionDelegate = object : NoiseSessionDelegate { + override fun hasEstablishedSession(peerID: String): Boolean = false + override fun initiateHandshake(peerID: String) = Unit + override fun getMyPeerID(): String = "" + }, + trackUnreadMessages = false + ) + private val geohashMessageHandler = GeohashMessageHandler( + application = application, + repo = geohashRepository, + scope = scope, + dataManager = dataManager, + addChannelMessage = AppStateStore::addChannelMessage + ) + private val directMessageHandler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + updateDeliveryStatus = ::updateDeliveryStatus, + scope = scope, + repo = geohashRepository, + dataManager = dataManager + ) + + init { + // Keep the headless state aligned with messages sent or received through other transports. + // This preserves duplicate detection and focused-conversation behavior without retaining UI. + scope.launch { + AppStateStore.privateMessages.collect(state::setPrivateChats) + } + scope.launch { + AppStateStore.nickname.collect(state::setNickname) + } + scope.launch { + AppStateStore.selectedPrivateChatPeer.collect(state::setSelectedPrivateChatPeer) + } + } + + fun onAccountDm(event: NostrEvent, identity: NostrIdentity) { + refreshBlockLists() + directMessageHandler.onGiftWrap(event, "", identity) + } + + fun onGeohashMessage(event: NostrEvent, geohash: String) { + refreshBlockLists() + geohashMessageHandler.onEvent(event, geohash) + } + + fun onGeohashDm(event: NostrEvent, geohash: String, identity: NostrIdentity) { + refreshBlockLists() + directMessageHandler.onGiftWrap(event, geohash, identity) + } + + fun conversationGeohash(conversationKey: String): String? = + geohashRepository.getConversationGeohash(conversationKey) + ?: GeohashConversationRegistry.get(conversationKey) + + fun displayNameForNostrPubkey(pubkeyHex: String): String = + geohashRepository.displayNameForNostrPubkeyUI(pubkeyHex) + + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = + geohashRepository.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + + private fun updateDeliveryStatus(messageId: String, status: DeliveryStatus) { + messageManager.updateMessageDeliveryStatus(messageId, status) + // The headless state may not yet contain a just-sent UI message. Update the process store + // unconditionally so a delivery/read receipt can never be lost during Activity handoff. + AppStateStore.updatePrivateMessageStatus(messageId, status) + } + + private fun refreshBlockLists() { + dataManager.loadBlockedUsers() + dataManager.loadGeohashBlockedUsers() + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt index 3931ea72..a895f612 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt @@ -23,28 +23,16 @@ import kotlin.random.asKotlinRandom /** * Process-owned Nostr connectivity and low-volume background subscriptions. * - * Stable subscription handlers delegate to the most recently attached UI-independent handlers. - * This avoids relay subscriptions retaining a cleared ViewModel while keeping DMs and the selected - * geohash channel alive for the lifetime of the foreground-service process. + * Stable subscriptions dispatch directly to a process-owned event processor. The UI hydrates from + * the process state store, so relay events remain useful without retaining a cleared ViewModel. */ object NostrBackgroundRuntime { private const val TAG = "NostrBackground" - private const val MAX_PENDING_EVENTS = 256 - - data class Handlers( - val accountDm: (NostrEvent, NostrIdentity) -> Unit, - val geohashMessage: (NostrEvent, String) -> Unit, - val geohashDm: (NostrEvent, String, NostrIdentity) -> Unit - ) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - internal val eventScope: CoroutineScope - get() = scope private val random = SecureRandom().asKotlinRandom() private val lock = Any() - private val pendingEvents = ArrayDeque<(Handlers) -> Unit>() - @Volatile private var handlers: Handlers? = null @Volatile private var initialized = false @Volatile private var activeGeohash: String? = null @Volatile private var activeGeohashLiveToken: Long? = null @@ -52,17 +40,20 @@ object NostrBackgroundRuntime { private lateinit var application: Application private lateinit var subscriptions: NostrSubscriptionManager private lateinit var locationChannels: LocationChannelManager + private lateinit var eventProcessor: NostrBackgroundEventProcessor fun initialize(app: Application) { synchronized(lock) { if (initialized) return - initialized = true application = app locationChannels = LocationChannelManager.getInstance(app) + eventProcessor = NostrBackgroundEventProcessor(app, scope) subscriptions = NostrSubscriptionManager( app, owner = NostrRelayManager.OWNER_BACKGROUND ) + // Publish readiness only after every process-owned dependency is available. + initialized = true } subscriptions.connect() @@ -71,14 +62,6 @@ object NostrBackgroundRuntime { startPresenceScheduler() } - fun attachHandlers(newHandlers: Handlers) { - handlers = newHandlers - val pending = synchronized(lock) { - pendingEvents.toList().also { pendingEvents.clear() } - } - pending.forEach { event -> runCatching { event(newHandlers) } } - } - fun resetSubscriptions() { if (!initialized) return subscriptions.unsubscribeAllOwned() @@ -116,7 +99,7 @@ object NostrBackgroundRuntime { sinceMs = System.currentTimeMillis() - 172_800_000L, id = "chat-messages", handler = { event -> - dispatch { it.accountDm(event, identity) } + eventProcessor.onAccountDm(event, identity) } ) } @@ -164,7 +147,7 @@ object NostrBackgroundRuntime { sinceMs = System.currentTimeMillis() - 3_600_000L, limit = 200, id = "geohash-$geohash", - handler = { event -> dispatch { it.geohashMessage(event, geohash) } }, + handler = { event -> eventProcessor.onGeohashMessage(event, geohash) }, liveLocationToken = liveLocationToken ) subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken) @@ -183,7 +166,7 @@ object NostrBackgroundRuntime { sinceMs = System.currentTimeMillis() - 172_800_000L, id = subscriptionId, handler = { event -> - dispatch { it.geohashDm(event, geohash, identity) } + eventProcessor.onGeohashDm(event, geohash, identity) }, liveLocationToken = liveLocationToken ) @@ -271,15 +254,19 @@ object NostrBackgroundRuntime { } } - private fun dispatch(event: (Handlers) -> Unit) { - val current = handlers - if (current != null) { - event(current) - return - } - synchronized(lock) { - if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.removeFirst() - pendingEvents.addLast(event) - } + fun conversationGeohash(conversationKey: String): String? = + if (initialized) eventProcessor.conversationGeohash(conversationKey) + else GeohashConversationRegistry.get(conversationKey) + + fun displayNameForNostrPubkey(pubkeyHex: String): String? = + if (initialized) eventProcessor.displayNameForNostrPubkey(pubkeyHex) else null + + fun displayNameForGeohashConversation( + pubkeyHex: String, + sourceGeohash: String + ): String? = if (initialized) { + eventProcessor.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + } else { + null } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 7a49057d..ac830096 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -15,7 +15,6 @@ import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState -import com.bitchat.android.ui.MeshDelegateHandler import com.bitchat.android.ui.PrivateChatManager import com.bitchat.android.ui.PrivateMessageOrigin import kotlinx.coroutines.CoroutineScope @@ -28,7 +27,7 @@ class NostrDirectMessageHandler( private val application: Application, private val state: ChatState, private val privateChatManager: PrivateChatManager, - private val meshDelegateHandler: MeshDelegateHandler, + private val updateDeliveryStatus: (String, DeliveryStatus) -> Unit, private val scope: CoroutineScope, private val repo: GeohashRepository, private val dataManager: com.bitchat.android.ui.DataManager @@ -54,7 +53,7 @@ class NostrDirectMessageHandler( } fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) { - scope.launch(Dispatchers.Default) { + scope.launch { try { if (dedupe(giftWrap.id)) return@launch @@ -178,13 +177,19 @@ class NostrDirectMessageHandler( NoisePayloadType.DELIVERED -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { - meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID) + updateDeliveryStatus( + messageId, + DeliveryStatus.Delivered(conversationID, Date()) + ) } } NoisePayloadType.READ_RECEIPT -> { val messageId = String(payload.data, Charsets.UTF_8) withContext(Dispatchers.Main) { - meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID) + updateDeliveryStatus( + messageId, + DeliveryStatus.Read(conversationID, Date()) + ) } } NoisePayloadType.FILE_TRANSFER -> { diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c88e1bc5..2007abf6 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -31,6 +31,12 @@ object AppStateStore { private val _privateMessages = MutableStateFlow>>(emptyMap()) val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() + + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() + // Channel messages by channel name private val _channelMessages = MutableStateFlow>>(emptyMap()) val channelMessages: StateFlow>> = _channelMessages.asStateFlow() @@ -41,6 +47,14 @@ object AppStateStore { } } + fun setNickname(nickname: String) { + _nickname.value = nickname + } + + fun setSelectedPrivateChatPeer(peerID: String?) { + _selectedPrivateChatPeer.value = peerID + } + fun setTransportPeers(transportId: String, ids: List) { synchronized(this) { peerIdsByTransport[transportId] = ids.toSet() @@ -221,6 +235,8 @@ object AppStateStore { _publicMessages.value = emptyList() _privateMessages.value = emptyMap() _channelMessages.value = emptyMap() + _nickname.value = "" + _selectedPrivateChatPeer.value = null } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 53a298ef..40593970 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -211,6 +211,7 @@ class ChatState( fun setNickname(nickname: String) { _nickname.value = nickname + com.bitchat.android.services.AppStateStore.setNickname(nickname) } fun setIsConnected(connected: Boolean) { @@ -223,6 +224,7 @@ class ChatState( fun setSelectedPrivateChatPeer(peerID: String?) { _selectedPrivateChatPeer.value = peerID + com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(peerID) } fun setUnreadPrivateMessages(unread: Set) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 72507d8b..efdab660 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -154,8 +154,6 @@ class ChatViewModel( application = application, state = state, messageManager = messageManager, - privateChatManager = privateChatManager, - meshDelegateHandler = meshDelegateHandler, dataManager = dataManager, notificationManager = notificationManager ) @@ -341,6 +339,7 @@ class ChatViewModel( override fun onCleared() { geohashViewModel.shutdownUiSubscriptions() + com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null) super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 4f37d47a..8517ec15 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -198,8 +198,10 @@ class DataManager(private val context: Context) { // MARK: - Blocked Users Management + @Synchronized fun loadBlockedUsers() { val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet() + _blockedUsers.clear() _blockedUsers.addAll(savedBlockedUsers) } @@ -217,6 +219,7 @@ class DataManager(private val context: Context) { saveBlockedUsers() } + @Synchronized fun isUserBlocked(fingerprint: String): Boolean { return _blockedUsers.contains(fingerprint) } @@ -226,8 +229,10 @@ class DataManager(private val context: Context) { private val _geohashBlockedUsers = mutableSetOf() // Set of nostr pubkey hex val geohashBlockedUsers: Set get() = _geohashBlockedUsers.toSet() + @Synchronized fun loadGeohashBlockedUsers() { val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet() + _geohashBlockedUsers.clear() _geohashBlockedUsers.addAll(savedGeohashBlockedUsers) } @@ -245,6 +250,7 @@ class DataManager(private val context: Context) { saveGeohashBlockedUsers() } + @Synchronized fun isGeohashUserBlocked(pubkeyHex: String): Boolean { return _geohashBlockedUsers.contains(pubkeyHex) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 46504649..d2b706ec 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -13,7 +13,6 @@ import com.bitchat.android.geohash.LiveLocationPrivacyGate import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.NostrBackgroundRuntime -import com.bitchat.android.nostr.NostrDirectMessageHandler import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager @@ -33,8 +32,6 @@ class GeohashViewModel( application: Application, private val state: ChatState, private val messageManager: MessageManager, - private val privateChatManager: PrivateChatManager, - private val meshDelegateHandler: MeshDelegateHandler, private val dataManager: DataManager, private val notificationManager: NotificationManager ) : AndroidViewModel(application), DefaultLifecycleObserver { @@ -49,20 +46,10 @@ class GeohashViewModel( ) private val geohashMessageHandler = GeohashMessageHandler( application = application, - state = state, - messageManager = messageManager, repo = repo, - scope = NostrBackgroundRuntime.eventScope, - dataManager = dataManager - ) - private val dmHandler = NostrDirectMessageHandler( - application = application, - state = state, - privateChatManager = privateChatManager, - meshDelegateHandler = meshDelegateHandler, - scope = NostrBackgroundRuntime.eventScope, - repo = repo, - dataManager = dataManager + scope = viewModelScope, + dataManager = dataManager, + addChannelMessage = messageManager::addChannelMessage ) // Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded. @@ -103,19 +90,6 @@ class GeohashViewModel( } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) - NostrBackgroundRuntime.attachHandlers( - NostrBackgroundRuntime.Handlers( - accountDm = { event, identity -> - dmHandler.onGiftWrap(event, "", identity) - }, - geohashMessage = { event, geohash -> - geohashMessageHandler.onEvent(event, geohash) - }, - geohashDm = { event, geohash, identity -> - dmHandler.onGiftWrap(event, geohash, identity) - } - ) - ) viewModelScope.launch { locationChannelManager?.selectedChannel?.collect { channel -> state.setSelectedLocationChannel(channel) @@ -329,14 +303,29 @@ class GeohashViewModel( } fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) { - val geohash = repo.getConversationGeohash(conversationKey) ?: return + val geohash = repo.getConversationGeohash(conversationKey) + ?: NostrBackgroundRuntime.conversationGeohash(conversationKey) + ?: return NostrBackgroundRuntime.ensureConversationDm(geohash) } - fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) - fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { + val foregroundName = repo.displayNameForNostrPubkeyUI(pubkeyHex) + return foregroundName.takeUnless { it == "anon" } + ?: NostrBackgroundRuntime.displayNameForNostrPubkey(pubkeyHex) + ?: foregroundName + } + + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { + val foregroundName = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + return foregroundName.takeUnless { it == "anon" } + ?: NostrBackgroundRuntime.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) + ?: foregroundName + } + fun conversationGeohash(conversationKey: String): String? = repo.getConversationGeohash(conversationKey) + ?: NostrBackgroundRuntime.conversationGeohash(conversationKey) fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = PeerIdentity.nostr(pubkeyHex) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 1af48a04..61102664 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -33,7 +33,8 @@ class PrivateChatManager( private val state: ChatState, private val messageManager: MessageManager, private val dataManager: DataManager, - private val noiseSessionDelegate: NoiseSessionDelegate + private val noiseSessionDelegate: NoiseSessionDelegate, + private val trackUnreadMessages: Boolean = true ) { companion object { @@ -337,7 +338,7 @@ class PrivateChatManager( // Nostr messages originate here and must be added explicitly, even after their // sender alias has canonicalized to a contact_* conversation ID. if (origin == PrivateMessageOrigin.NOSTR) { - if (suppressUnread) { + if (suppressUnread || !trackUnreadMessages) { messageManager.addPrivateMessageNoUnread(conversationID, message) } else { messageManager.addPrivateMessage(conversationID, message) @@ -345,7 +346,10 @@ class PrivateChatManager( } // Track as unread for read receipt purposes if not focused - if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) { + if (trackUnreadMessages && + !suppressUnread && + state.getSelectedPrivateChatPeerValue() != conversationID + ) { val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() } unreadList.add(message) Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})") diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt new file mode 100644 index 00000000..d7fffc2c --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.nostr + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.services.AppStateStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrBackgroundEventProcessorTest { + private lateinit var scope: CoroutineScope + + @Before + fun setUp() { + AppStateStore.clear() + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } + + @After + fun tearDown() { + scope.cancel() + AppStateStore.clear() + } + + @Test + fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking { + val application = ApplicationProvider.getApplicationContext() + val processor = NostrBackgroundEventProcessor(application, scope) + + repeat(300) { index -> + processor.onGeohashMessage( + event = NostrEvent( + id = "cold-start-$index", + pubkey = index.toString(16).padStart(64, '0'), + createdAt = 1, + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", "u4pruy")), + content = "message-$index" + ), + geohash = "u4pruy" + ) + } + + withTimeout(5_000) { + while (AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().size < 300) { + kotlinx.coroutines.yield() + } + } + + assertEquals( + (0 until 300).map { "cold-start-$it" }.toSet(), + AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet() + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt index 852411e8..e8d1f19e 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -67,6 +68,34 @@ class PrivateChatManagerTest { assertEquals(listOf(message), state.getPrivateChatsValue()[conversationID]) } + @Test + fun `headless Nostr processing stores messages without retaining UI unread work`() { + val headlessManager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = DataManager(RuntimeEnvironment.getApplication()), + noiseSessionDelegate = mock(), + trackUnreadMessages = false + ) + val message = BitchatMessage( + id = "background-nostr-message", + sender = "alice", + content = "background", + timestamp = Date(1), + isPrivate = true, + senderPeerID = "nostr_background" + ) + + headlessManager.handleIncomingPrivateMessage( + message = message, + suppressUnread = false, + origin = PrivateMessageOrigin.NOSTR + ) + + assertEquals(listOf(message), AppStateStore.privateMessages.value["nostr_background"]) + assertTrue(state.getUnreadPrivateMessagesValue().isEmpty()) + } + @Test fun `canonical conversation sends read receipt through live mesh peer id`() { val noiseKey = ByteArray(32) { 9 } From fa63a98e7716a58cca20b65de9bb60ecf3d451a2 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:28:05 +0200 Subject: [PATCH 37/37] Update Nostr handler regression test --- .../com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index 738b3231..2ddffff1 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -5,7 +5,6 @@ import com.bitchat.android.services.AppStateStore import com.bitchat.android.services.SeenMessageStore import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.DataManager -import com.bitchat.android.ui.MeshDelegateHandler import com.bitchat.android.ui.MessageManager import com.bitchat.android.ui.NoiseSessionDelegate import com.bitchat.android.ui.PrivateChatManager @@ -72,7 +71,7 @@ class NostrDirectMessageHandlerTest { application = application, state = state, privateChatManager = privateChatManager, - meshDelegateHandler = mock(), + updateDeliveryStatus = { _, _ -> }, scope = scope, repo = GeohashRepository(application, state, dataManager), dataManager = dataManager,