diff --git a/.gitignore b/.gitignore index 42117323..0986ac74 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ google-services.json # Arti build artifacts (cloned repo and Rust build cache) tools/arti-build/.arti-source/ tools/arti-build/target/ + +# JVM heap dumps (a Gradle daemon OOM drops these in the repo root) +*.hprof 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/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/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-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/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/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index 94529d77..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,10 +15,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +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 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 @@ -30,6 +38,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) } @@ -37,33 +46,47 @@ fun BitChatBrandButton( val currentOnClick by rememberUpdatedState(onClick) val currentOnTripleClick by rememberUpdatedState(onTripleClick) - IconButton( - onClick = { - tapCount += 1 - resetJob?.cancel() + val interactionSource = remember { MutableInteractionSource() } + val pressScale = rememberPressScale(interactionSource) - if (tapCount == 3) { - tapCount = 0 - resetJob = null - currentOnTripleClick() - } else { - resetJob = coroutineScope.launch { - delay(MultiClickThreshold) - if (tapCount == 1) { - currentOnClick() - } + // 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() + } + tapCount = 0 + resetJob = null + } } - } - }, - modifier = modifier, + }, + contentAlignment = Alignment.Center ) { Icon( imageVector = BitChatIcon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(16.dp), + modifier = Modifier + .size(iconSize) + .scale(pressScale), ) } } 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..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 @@ -1,34 +1,38 @@ 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.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.bitchat.android.R @Composable fun CloseButton( onClick: () -> Unit, - modifier: Modifier = Modifier.Companion + modifier: Modifier = Modifier ) { + val colorScheme = MaterialTheme.colorScheme 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 = colorScheme.primary, + containerColor = Color.Transparent ) ) { 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/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt index 948fd6d9..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 @@ -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,20 @@ 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() + } + } + } + ModalBottomSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismissRequest, @@ -27,6 +59,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..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,9 +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) @@ -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) ) }, @@ -80,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/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 8e1731b1..528a269e 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -69,7 +69,15 @@ data class BitchatMessage( val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, val deliveryStatus: DeliveryStatus? = null, - val powDifficulty: Int? = null + val powDifficulty: Int? = null, + /** + * Full canonical Nostr public key supplied by the local Nostr bridge. + * + * This is local identity metadata, not part of the Bitchat binary wire format. It lets UI + * surfaces color the sender by the same stable key while [senderPeerID] remains available for + * mesh IDs and private-chat routing aliases. + */ + val senderNostrPubkey: String? = null ) : Parcelable { /** @@ -355,4 +363,3 @@ data class BitchatMessage( } } - 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 1c4896f3..1fc802d9 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -96,6 +96,7 @@ class GeohashMessageHandler( isRelay = false, originalSender = repo.displayNameForNostrPubkey(pubkey), senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, mentions = null, channel = "#$subscribedGeohash", powDifficulty = try { 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 9d8ae8af..7a49057d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -148,6 +148,7 @@ class NostrDirectMessageHandler( isPrivate = true, recipientNickname = state.getNicknameValue(), senderPeerID = conversationID, + senderNostrPubkey = senderPubkey, deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date()) ) @@ -201,7 +202,8 @@ class NostrDirectMessageHandler( isRelay = false, isPrivate = true, recipientNickname = state.getNicknameValue(), - senderPeerID = conversationID + senderPeerID = conversationID, + senderNostrPubkey = senderPubkey ) Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)") withContext(Dispatchers.Main) { 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..50a58beb 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 @@ -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 3fe392d6..713fe7f8 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 @@ -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 60c6e5e4..3eb6eea9 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt @@ -1,20 +1,20 @@ 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 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 c5bb8c9b..3654aba8 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -1,21 +1,21 @@ 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 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 48138d66..afd91da3 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,17 +9,21 @@ 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 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 new file mode 100644 index 00000000..e36ee7be --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt @@ -0,0 +1,605 @@ +package com.bitchat.android.ui + +import androidx.annotation.DrawableRes +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.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.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 +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 +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) + +/** 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 + +/** + * 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 + +/** + * Two top-level views of the sheet: what the app is and how to drive it, versus the knobs. + */ +enum class AboutTab { + Info, + Settings, +} + +/** + * 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 = BitchatFontFamily, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.8.sp, + color = palette.textTertiary, + modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp) + ) +} + +/** + * Icon + title on one line, optional short subtitle beneath. Used by location / network sheets. + */ +@Composable +internal fun SheetIconSectionHeader( + @DrawableRes iconRes: Int, + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Text( + text = title, + fontSize = 17.sp, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + color = colorScheme.primary + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + fontSize = 12.sp, + lineHeight = 17.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } + } +} + +/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */ +@Composable +internal fun SheetCardDivider() { + val colorScheme = MaterialTheme.colorScheme + HorizontalDivider( + modifier = Modifier.padding(start = SheetRowDividerInset), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) +} + +/** + * 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 = BitchatFontFamily, + 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 = BitchatFontFamily, + fontSize = 16.sp, + color = colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = stringResource(R.string.version_prefix, versionName), + fontFamily = BitchatFontFamily, + 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 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.Info) 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_info), + isSelected = selected == AboutTab.Info, + onClick = { onSelect(AboutTab.Info) }, + modifier = Modifier.weight(1f) + ) + AboutTabLabel( + text = stringResource(R.string.about_tab_settings), + isSelected = selected == AboutTab.Settings, + onClick = { onSelect(AboutTab.Settings) }, + modifier = Modifier.weight(1f) + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + Box( + modifier = Modifier + // 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) + ) + } + } +} + +@Composable +private fun AboutTabLabel( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + val color by animateColorAsState( + targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant, + 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 = BitchatFontFamily, + 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( + @DrawableRes iconRes: Int, + text: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Text( + text = text, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + lineHeight = 20.sp, + color = colorScheme.onSurface + ) + } +} + +/** + * 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 = BitchatFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + top = 20.dp, + bottom = 8.dp + ) + ) + + AboutInstructionRow( + iconRes = R.drawable.ic_spec_person, + text = stringResource(R.string.about_howto_nickname) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_globe, + text = stringResource(R.string.about_howto_channels) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_people, + text = stringResource(R.string.about_howto_people) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_bookmark_outline, + text = stringResource(R.string.about_howto_bookmark) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_mention, + text = stringResource(R.string.about_howto_mention) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_command, + text = stringResource(R.string.about_howto_commands) + ) + AboutInstructionRow( + iconRes = R.drawable.ic_spec_waveform, + text = stringResource(R.string.about_howto_panic) + ) + } +} + +/** + * Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers. + */ +@Composable +internal fun AboutFeatureCard(modifier: Modifier = Modifier) { + val features = listOf( + Triple( + R.drawable.ic_spec_wifi_off, + R.string.about_offline_mesh_title, + R.string.about_offline_mesh_desc + ), + Triple( + R.drawable.ic_spec_lock, + R.string.about_e2e_title, + R.string.about_e2e_desc + ), + Triple( + R.drawable.ic_spec_globe, + R.string.about_online_geohash_title, + R.string.about_online_geohash_desc + ), + Triple( + R.drawable.ic_spec_eye_off, + R.string.about_no_tracking_title, + R.string.about_no_tracking_desc + ), + ) + + Column(modifier = modifier.fillMaxWidth()) { + features.forEach { (icon, titleRes, descRes) -> + AboutFeatureRow( + iconRes = icon, + title = stringResource(titleRes), + subtitle = stringResource(descRes) + ) + } + } +} + +@Composable +private fun AboutFeatureRow( + @DrawableRes iconRes: Int, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 1.dp) + .size(22.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + lineHeight = 20.sp, + color = colorScheme.onSurface + ) + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + lineHeight = 17.sp, + color = colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle. + */ +@Composable +internal fun BitchatBadge( + text: String, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp)) + .padding(horizontal = 5.dp, vertical = 2.dp) + ) { + Text( + text = text.uppercase(), + fontFamily = BitchatFontFamily, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + color = colorScheme.primary + ) + } +} + +// 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 = BitchatFontFamily, + 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 + Box( + modifier = modifier + .size(44.dp) + .background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(26.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 colorScheme = MaterialTheme.colorScheme + val accent = if (isDestructive) colorScheme.error else colorScheme.primary + + 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 = BitchatFontFamily, + 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 7a64ff50..67065182 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -3,11 +3,19 @@ package com.bitchat.android.ui import android.content.Intent import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +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.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Speed import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -26,43 +34,17 @@ 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.ChevronRight import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -70,14 +52,15 @@ 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 import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.hotspot.HotspotActivity import com.bitchat.android.net.ArtiTorManager @@ -85,51 +68,10 @@ import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.ui.theme.BitchatMotion +import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.util.UniversalApkManager -/** - * Feature row for displaying app capabilities - */ -@Composable -private fun FeatureRow( - icon: ImageVector, - title: String, - subtitle: String -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(22.dp) - ) - Spacer(modifier = Modifier.width(14.dp)) - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - lineHeight = 18.sp - ) - } - } -} - /** * Theme selection chip with Apple-like styling */ @@ -141,17 +83,25 @@ private fun ThemeChip( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - + + // 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) colorScheme.primary else colorScheme.surfaceVariant, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "themeChipContainer" + ) + val labelColor by animateColorAsState( + targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant, + 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 @@ -161,9 +111,10 @@ private fun ThemeChip( ) { Text( text = label, - style = MaterialTheme.typography.bodySmall, + fontFamily = BitchatFontFamily, + 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 ) } } @@ -184,23 +135,49 @@ 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 + 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) colorScheme.onSurface else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowTitle" + ) + val subtitleColor by animateColorAsState( + targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowSubtitle" + ) + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + // 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 colorScheme.onSurface.copy(alpha = 0.3f), + tint = iconTint, 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) @@ -211,29 +188,32 @@ private fun SettingsToggleRow( ) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f) + color = titleColor ) 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 = BitchatFontFamily, + fontSize = 12.sp, + color = subtitleColor, + lineHeight = 17.sp ) } - + Spacer(modifier = Modifier.width(16.dp)) - + Switch( checked = checked, onCheckedChange = { if (enabled) onCheckedChange(it) }, enabled = enabled, + interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + checkedTrackColor = colorScheme.primary, uncheckedThumbColor = Color.White, uncheckedTrackColor = colorScheme.surfaceVariant ) @@ -272,12 +252,14 @@ 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 + var selectedTab by remember { mutableStateOf(AboutTab.Info) } + if (isPresented) { BitchatBottomSheet( modifier = modifier, @@ -287,103 +269,49 @@ 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 ?: "") } - // 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) - ) - } + item(key = "tabs") { + AboutTabBar( + selected = selectedTab, + onSelect = { selectedTab = it }, + modifier = Modifier.padding(top = 24.dp) + ) + } + + 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.Settings) { // 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(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Row( modifier = Modifier @@ -425,18 +353,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(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column { // Background Mode Toggle @@ -455,12 +379,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 = colorScheme.outlineVariant ) - + // Proof of Work Toggle SettingsToggleRow( icon = Icons.Filled.Speed, @@ -469,16 +394,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 = colorScheme.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 -> @@ -491,9 +417,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 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error } Surface( color = statusColor, @@ -857,15 +783,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) + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + 16.dp, + top = 8.dp + ) ) } } @@ -875,13 +804,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(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -893,31 +824,39 @@ fun AboutSheet( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Difficulty", - style = MaterialTheme.typography.bodyMedium, + text = stringResource(R.string.about_difficulty), + fontFamily = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colorScheme.onSurface ) - Text( - text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) + AnimatedCountLabel( + count = powDifficulty, + text = stringResource( + R.string.about_difficulty_value, + powDifficulty, + NostrProofOfWork.estimateMiningTime(powDifficulty) + ), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = colorScheme.onSurfaceVariant ) } - + 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 = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.surfaceVariant ) ) - - 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) @@ -928,8 +867,8 @@ fun AboutSheet( else -> stringResource(R.string.about_pow_desc_extreme) }, fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) } } @@ -942,13 +881,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(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), color = colorScheme.surface, - shape = RoundedCornerShape(16.dp) + shape = AboutCardShape ) { Column( modifier = Modifier.padding(16.dp), @@ -959,14 +900,19 @@ 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 -> colorScheme.primary + torStatus.running -> palette.accentOrange + else -> colorScheme.error } 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 = BitchatFontFamily, + fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colorScheme.onSurface ) @@ -975,8 +921,8 @@ fun AboutSheet( Text( text = torStatus.lastLogLine.take(120), fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = BitchatFontFamily, + color = palette.textTertiary, maxLines = 2 ) } @@ -986,67 +932,36 @@ fun AboutSheet( } } - // Emergency 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) - ) - } - } - } - } + } // end Settings 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) ) { - if (onShowDebug != null) { + if (selectedTab == AboutTab.Settings && onShowDebug != null) { TextButton(onClick = onShowDebug) { Text( text = stringResource(R.string.about_debug_settings), fontSize = 13.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.primary ) } } Text( text = stringResource(R.string.about_footer), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.4f) + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) Spacer(modifier = Modifier.height(20.dp)) } @@ -1059,10 +974,11 @@ fun AboutSheet( .align(Alignment.TopCenter) .fillMaxWidth() .height(64.dp) - .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) + .background(colorScheme.background.copy(alpha = topBarAlpha)) ) { + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onDismiss, + onClick = { dismiss?.invoke() ?: onDismiss() }, modifier = modifier .align(Alignment.CenterEnd) .padding(horizontal = 16.dp), @@ -1112,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/AnimatedCount.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt new file mode 100644 index 00000000..ecf5eb69 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt @@ -0,0 +1,117 @@ +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.runtime.remember +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 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" + ) { state -> + // Captured per state, so the outgoing copy keeps rendering the label it entered with. + // Reading `text` directly would show the *new* label on both sides of the cross-fade, + // turning the transition into a flicker between two identical strings. + val stateText = remember(state) { text } + Text( + text = stateText, + style = style, + color = color, + fontSize = fontSize, + fontWeight = fontWeight, + fontFamily = fontFamily, + maxLines = 1 + ) + } +} 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 e91e30be..af38ea1d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,67 +1,277 @@ package com.bitchat.android.ui - -import android.util.Log +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.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.* 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.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.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.graphics.painter.Painter import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.bitchat.android.R 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.ui.theme.BitchatFontFamily +import androidx.annotation.DrawableRes +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.layout.RowScope +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 +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 44.dp tap targets fit properly. */ +val ChatHeaderHeight = 52.dp +/** + * The single visible glyph size used by every icon in the top bar. + * + * 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 = 19.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 = 44.dp + +/** Corner radius for the header's tappable label+icon clusters. */ +private val HeaderClusterShape = RoundedCornerShape(8.dp) + +/** + * Edge insets for the bar. + * + * Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies + * some optical inset, while the trailing action's does the same on the other side. + */ +internal val HeaderInsetStart = 12.dp +internal val HeaderInsetEnd = 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) + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), + contentAlignment = Alignment.Center + ) { + content() + } +} + +/** + * Tor health for location-channel header glyphs. + * + * 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 -fun TorStatusDot( - modifier: Modifier = Modifier +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, colorScheme.error, 0.30f) + + val target = when { + 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 animatedTint by animateColorAsState( + targetValue = target.tint, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "torConnectionTint" + ) + 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 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 - } - Canvas( - modifier = modifier - ) { - val radius = size.minDimension / 2 - drawCircle( - color = dotColor, - radius = radius, - center = Offset(size.width / 2, size.height / 2) + 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 + } + + // 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) { + 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( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f + }, + tint = tint + ) + } +} + +/** 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 + ) } } @@ -70,39 +280,156 @@ fun NoiseSessionIcon( sessionState: String?, modifier: Modifier = Modifier ) { - val (icon, color, contentDescription) = when (sessionState) { + 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 (iconRes, color, contentDescription) = when (sessionState) { "uninitialized" -> Triple( - Icons.Outlined.NoEncryption, - Color(0x87878700), // Grey - ready to establish + R.drawable.ic_spec_lock_open, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_ready_for_handshake) ) "handshaking" -> Triple( - Icons.Outlined.Sync, - Color(0x87878700), // Grey - in progress + R.drawable.ic_spec_sync, + colorScheme.onSurfaceVariant, stringResource(R.string.cd_handshake_in_progress) ) "established" -> Triple( - Icons.Filled.Lock, - Color(0xFFFF9500), // Orange - secure + R.drawable.ic_spec_lock, + colorScheme.primary, stringResource(R.string.cd_encrypted) ) else -> { // "failed" or any other state Triple( - Icons.Outlined.Warning, - Color(0xFFFF4444), // Red - error + R.drawable.ic_spec_warning, + colorScheme.error, stringResource(R.string.cd_handshake_failed) ) } } - + Icon( - imageVector = icon, + painter = painterResource(iconRes), contentDescription = contentDescription, modifier = modifier, tint = color ) } +/** + * Reachability glyph for a conversation, drawn from the same spec set the main header uses. + * + * Mirrors the main header's channel button: a globe for anything reached over the internet, the + * range mark for the local mesh, and the more specific transport glyph when we know it. + */ +@DrawableRes +internal fun conversationTransportIcon( + isReachedOverInternet: Boolean, + isWifiAware: Boolean, + isDirect: Boolean +): Int = when { + isReachedOverInternet -> R.drawable.ic_spec_globe + isWifiAware -> R.drawable.ic_spec_wifi + isDirect -> R.drawable.ic_spec_bluetooth + else -> R.drawable.ic_spec_routed +} + +/** + * The shared chrome for a conversation header — private chats and channels alike. + * + * Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical + * height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it + * lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward + * that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you + * move between the main timeline and a conversation. + * + * Actions are right-aligned and unweighted, so a long title yields space to them rather than + * pushing them off screen. + */ +@Composable +fun ConversationHeader( + @DrawableRes leadingIconRes: Int, + leadingIconTint: Color, + title: String, + modifier: Modifier = Modifier, + onTitleClick: (() -> Unit)? = null, + leadingContentDescription: String? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(HeaderTapTarget), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(leadingIconRes), + contentDescription = leadingContentDescription, + modifier = Modifier.size(HeaderIconSize), + tint = leadingIconTint + ) + } + + // Same optical correction as the main header: the 44.dp tap target leaves more gap + // than the design wants between glyph and label. + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .offset(x = (-6).dp) + .then( + if (onTitleClick != null) { + Modifier + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onTitleClick) + .padding(horizontal = 6.dp, vertical = 4.dp) + } else { + Modifier + } + ) + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + content = actions + ) + } +} + +/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */ +@Composable +fun ConversationHeaderAction( + onClick: () -> Unit, + contentDescription: String?, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) = HeaderIconButton( + onClick = onClick, + contentDescription = contentDescription, + modifier = modifier, + content = content +) + @Composable fun NicknameEditor( value: String, @@ -125,15 +452,17 @@ fun NicknameEditor( Text( text = stringResource(R.string.at_symbol), style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary.copy(alpha = 0.8f) + 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 = BitchatFontFamily, + fontSize = HeaderTextSize ), cursorBrush = SolidColor(colorScheme.primary), singleLine = true, @@ -144,7 +473,7 @@ fun NicknameEditor( } ), modifier = Modifier - .widthIn(max = 120.dp) + .widthIn(max = 150.dp) .horizontalScroll(scrollState) ) } @@ -161,54 +490,68 @@ fun PeerCounter( onClick: () -> Unit, 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 - val green = Color(0xFF00C851) // Standard green - Pair(count, if (count > 0) green else Color.Gray) + 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 - 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) colorScheme.secondary 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(6.dp), + modifier = modifier + .clip(HeaderClusterShape) + .pressScaleClickable(onClick = onClick) + .height(HeaderTapTarget) + .padding(horizontal = 6.dp) ) { Icon( - imageVector = Icons.Default.Group, + // 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) 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", + AnimatedCount( + count = peopleCount, style = MaterialTheme.typography.bodyMedium, - color = countColor, - fontSize = 16.sp, + 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, - color = if (isConnected) Color(0xFF00C851) else Color.Red, - fontSize = 16.sp, + fontSize = HeaderTextSize, + color = if (isConnected) colorScheme.primary else colorScheme.error, fontWeight = FontWeight.Medium ) } @@ -266,57 +609,26 @@ private fun ChannelHeader( onSidebarClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - - Box(modifier = Modifier.fillMaxWidth()) { - // Back button - positioned all the way to the left with minimal margin - Button( + + // No back affordance: the close action on the right is the way out, exactly as in a private + // chat. Leaving the channel outright lives on its row in the network sheet, so it does not + // need a second, easily-mistaken home next to the exit. + ConversationHeader( + leadingIconRes = R.drawable.ic_spec_chat_bubbles, + leadingIconTint = colorScheme.primary, + leadingContentDescription = null, + title = "#$channel", + onTitleClick = onSidebarClick + ) { + ConversationHeaderAction( 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.close_plain) ) { - 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 - ) - } - } - - // Title - perfectly centered regardless of other elements - Text( - text = stringResource(R.string.chat_channel_prefix, channel), - style = MaterialTheme.typography.titleMedium, - color = Color(0xFFFF9500), // Orange to match input field - modifier = Modifier - .align(Alignment.Center) - .clickable { onSidebarClick() } - ) - - // Leave button - positioned on the right - TextButton( - onClick = onLeaveChannel, - modifier = Modifier.align(Alignment.CenterEnd) - ) { - Text( - text = stringResource(R.string.chat_leave), - style = MaterialTheme.typography.bodySmall, - color = Color.Red + Icon( + painter = painterResource(R.drawable.ic_spec_close), + contentDescription = stringResource(R.string.close_plain), + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.onSurfaceVariant ) } } @@ -334,6 +646,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() @@ -342,109 +655,98 @@ 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(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(ChatHeaderHeight) + .padding(start = HeaderInsetStart, end = HeaderInsetEnd), verticalAlignment = Alignment.CenterVertically ) { + // 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.fillMaxHeight(), + 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, - color = colorScheme.primary, - ) + // 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) + ) - Spacer(modifier = Modifier.width(2.dp)) - - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) - } - - // Right section with location channels button and peer counter - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.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) + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange ) } + } + + // MARK: - Status cluster. + // + // 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) + ) { + // Unread private messages badge (click to open most recent DM) + if (hasUnreadPrivateMessages.isNotEmpty()) { + HeaderIconButton( + onClick = { viewModel.openLatestUnreadPrivateChat() }, + contentDescription = stringResource(R.string.cd_unread_private_messages) + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_private_messages), + modifier = Modifier.size(HeaderIconSize), + tint = palette.accentOrange + ) + } + } + + // 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. Duplicating it here bought a shortcut for a rare action at the cost + // of a slot in the app's most crowded row. - // 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) - ) - } - } } - // Location Notes button (extracted to separate component) - LocationNotesButton( - viewModel = viewModel, - onClick = onLocationNotesClick - ) - - // Tor status dot when Tor is enabled - TorStatusDot( - modifier = Modifier - .size(8.dp) - .padding(start = 0.dp, end = 2.dp) - ) - - // PoW status indicator - PoWStatusIndicator( - modifier = Modifier, - style = PoWIndicatorStyle.COMPACT - ) - Spacer(modifier = Modifier.width(2.dp)) PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, joinedChannels = joinedChannels, @@ -458,56 +760,72 @@ private fun MainHeader( } } +/** + * 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 + * and 40.dp minimum height that fought the header's explicit sizing. + */ @Composable private fun LocationChannelsButton( viewModel: ChatViewModel, onClick: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - + // 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) { + // 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 mesh glyph already says what it is, + // so it is plain "mesh". + else -> stringResource(R.string.mesh_label) } - - 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) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = badgeText, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = badgeColor, - maxLines = 1 + 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) + } else { + TorConnectionVisual(tint = channelColor, isProgress = false) + } + val badgeIconRes = if (isLocation) { + R.drawable.ic_spec_globe + } else { + R.drawable.ic_spec_range + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(HeaderClusterShape) + .pressScaleClickable( + onClick = onClick, + onClickLabel = stringResource(R.string.location_channels_title) ) - - // 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 - ) - } - } + .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. + .padding(start = 0.dp, end = 6.dp) + ) { + TorAwareHeaderIcon( + painter = painterResource(badgeIconRes), + tint = torVisual.tint, + isProgress = torVisual.isProgress, + contentDescription = stringResource(R.string.cd_tor_status) + ) + + Text( + text = badgeText, + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + fontWeight = FontWeight.Medium, + color = channelColor, + maxLines = 1 + ) } } 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 0514217d..e9c8e343 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -1,33 +1,36 @@ package com.bitchat.android.ui + +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 +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 import androidx.compose.foundation.* 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.ui.platform.LocalContext -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 +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 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 @@ -42,6 +45,7 @@ import com.bitchat.android.model.BitchatMessage 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 /** * Main ChatScreen - REFACTORED to use component-based architecture @@ -58,6 +62,8 @@ fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme val messages by viewModel.messages.collectAsStateWithLifecycle() val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() @@ -179,6 +185,47 @@ 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" + } + } + } + + val mentionPeerIdentities = remember( + displayMessages, + currentChannel, + selectedLocationChannel, + connectedPeers, + peerNicknames, + geohashPeople, + ) { + val knownPeers = if ( + currentChannel == null && selectedLocationChannel is ChannelID.Location + ) { + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) + geohashPeople.mapNotNull { person -> + if (isUnannouncedNickname(person.displayName)) return@mapNotNull null + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + displayName to PeerIdentity.nostr(person.id) + } + } else { + connectedPeers.mapNotNull { peerID -> + peerNicknames[peerID]?.let { displayName -> + displayName to PeerIdentity.mesh(peerID) + } + } + } + buildMentionPeerIdentityMap(displayMessages, knownPeers) + } + // Determine whether to show media buttons (only hide in geohash location chats) val showMediaButtons = when { currentChannel != null -> true @@ -191,8 +238,15 @@ fun ChatScreen(viewModel: ChatViewModel) { .fillMaxSize() .background(colorScheme.background) // Extend background to fill entire screen including status bar ) { - val headerHeight = 42.dp - + 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 @@ -200,100 +254,92 @@ 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 + // Nearby-notes strip and the reveal hint both live in this Box alongside the + // list, rather than in a Column above it, because the conversation has to scroll + // underneath the translucent bars. Their heights are reserved as list padding. + var notesStripHeight by remember { mutableStateOf(0.dp) } + val showNotesStrip = + isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty() + + MessagesList( + messages = displayMessages, + currentUserNickname = nickname, + meshService = viewModel.meshServiceFacade, + mentionPeerIdentities = mentionPeerIdentities, + modifier = Modifier.fillMaxSize(), + conversationKey = conversationKey, + contentPadding = PaddingValues( + top = statusBarHeight + headerHeight + + (if (showNotesStrip) notesStripHeight else 0.dp), + bottom = composerHeight + ), + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { fullSenderName -> + // Single click - mention user in text input + val currentText = messageText.text + + // Extract base nickname and hash suffix from full sender name + val (baseName, hashSuffix) = splitSuffix(fullSenderName) + + // Check if we're in a geohash channel to include hash suffix + val selectedLocationChannel = viewModel.selectedLocationChannel.value + val mentionText = if ( + selectedLocationChannel is ChannelID.Location && + hashSuffix.isNotEmpty() + ) { + // In geohash chat - include the hash suffix from the full display name + "@$baseName$hashSuffix" + } else { + // Regular chat - just the base nickname + "@$baseName" + } + + val newText = when { + currentText.isEmpty() -> "$mentionText " + currentText.endsWith(" ") -> "$currentText$mentionText " + else -> "$currentText $mentionText " + } + + messageText = TextFieldValue( + text = newText, + selection = TextRange(newText.length) + ) + }, + onMessageLongPress = { message -> + // Message long press - open user action sheet with message context + // Extract base nickname from message sender (contains all necessary info) + val (baseName, _) = splitSuffix(message.sender) + selectedUserForSheet = baseName + selectedMessageForSheet = message + showUserSheet = true + }, + onCancelTransfer = { msg -> + viewModel.cancelMediaSend(msg.id) + }, + onImageClick = { currentPath, allImagePaths, initialIndex -> + viewerImagePaths = allImagePaths + initialViewerIndex = initialIndex + showFullScreenImageViewer = true + } ) - // Messages area - takes up available space, will compress when keyboard appears - Column(modifier = Modifier.weight(1f)) { - if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) { - NearbyNotesStrip( - noteCount = nearbyNotes.size, - onClick = { showLocationNotesSheet = true }, - ) - } - - Box( + if (showNotesStrip) { + NearbyNotesStrip( + noteCount = nearbyNotes.size, + onClick = { showLocationNotesSheet = true }, modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) { - MessagesList( - messages = displayMessages, - currentUserNickname = nickname, - meshService = viewModel.meshServiceFacade, - modifier = Modifier.fillMaxSize(), - forceScrollToBottom = forceScrollToBottom, - onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, - onNicknameClick = { fullSenderName -> - // Single click - mention user in text input - val currentText = messageText.text - - // Extract base nickname and hash suffix from full sender name - val (baseName, hashSuffix) = splitSuffix(fullSenderName) - - // Check if we're in a geohash channel to include hash suffix - val selectedLocationChannel = viewModel.selectedLocationChannel.value - val mentionText = if ( - selectedLocationChannel is ChannelID.Location && - hashSuffix.isNotEmpty() - ) { - // In geohash chat - include the hash suffix from the full display name - "@$baseName$hashSuffix" - } else { - // Regular chat - just the base nickname - "@$baseName" - } - - val newText = when { - currentText.isEmpty() -> "$mentionText " - currentText.endsWith(" ") -> "$currentText$mentionText " - else -> "$currentText $mentionText " - } - - messageText = TextFieldValue( - text = newText, - selection = TextRange(newText.length), - ) + .align(Alignment.TopCenter) + .padding(top = statusBarHeight + headerHeight) + .onSizeChanged { size -> + notesStripHeight = with(density) { size.height.toDp() } }, - onMessageLongPress = { message -> - // Message long press - open user action sheet with message context - // Extract base nickname from message sender (contains all necessary info) - val (baseName, _) = splitSuffix(message.sender) - selectedUserForSheet = baseName - selectedMessageForSheet = message - showUserSheet = true - }, - onCancelTransfer = { msg -> - viewModel.cancelMediaSend(msg.id) - }, - onImageClick = { currentPath, allImagePaths, initialIndex -> - viewerImagePaths = allImagePaths - initialViewerIndex = initialIndex - showFullScreenImageViewer = true - }, - ) - - if ( - displayMessages.isEmpty() && - isMeshTimeline && - !nearbyNotesRevealed && - locationEnabled && - locationPermissionState == - LocationChannelManager.PermissionState.AUTHORIZED && - buildingGeohash != null - ) { - NearbyNotesRevealHint( - onClick = nearbyNotesController::reveal, - modifier = Modifier.align(Alignment.Center), - ) - } - } + ) } - // 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 -> @@ -302,6 +348,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 @@ -329,6 +380,7 @@ fun ChatScreen(viewModel: ChatViewModel) { commandSuggestions = commandSuggestions, showMentionSuggestions = showMentionSuggestions, mentionSuggestions = mentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, onCommandSuggestionClick = { suggestion: CommandSuggestion -> val commandText = viewModel.selectCommandSuggestion(suggestion) messageText = TextFieldValue( @@ -349,11 +401,11 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, showMediaButtons = showMediaButtons ) + } } // Floating header - positioned absolutely at top, ignores keyboard ChatFloatingHeader( - headerHeight = headerHeight, selectedPrivatePeer = null, currentChannel = currentChannel, nickname = nickname, @@ -369,40 +421,39 @@ fun ChatScreen(viewModel: ChatViewModel) { } ) - // Divider under header - positioned after status bar + header height - HorizontalDivider( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .offset(y = headerHeight) - .zIndex(1f), - color = colorScheme.outline.copy(alpha = 0.3f) - ) - // 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) + .padding(end = 16.dp, bottom = composerHeight + 8.dp) .zIndex(1.5f) .windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.ime) ) { Surface( shape = CircleShape, - color = colorScheme.background, + color = colorScheme.surface, tonalElevation = 3.dp, shadowElevation = 6.dp, - border = BorderStroke(2.dp, Color(0xFF00C851)) + 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), - tint = Color(0xFF00C851) + modifier = Modifier.size(22.dp), + tint = colorScheme.primary ) } } @@ -487,30 +538,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, @@ -537,7 +564,7 @@ private fun NearbyNotesStrip( }, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.primary, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, ) Text( @@ -561,58 +588,115 @@ fun ChatInputSection( commandSuggestions: List, showMentionSuggestions: Boolean, mentionSuggestions: List, + mentionPeerIdentities: Map = emptyMap(), onCommandSuggestionClick: (CommandSuggestion) -> Unit, onMentionSuggestionClick: (String) -> Unit, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, colorScheme: ColorScheme, - showMediaButtons: Boolean + showMediaButtons: Boolean, + modifier: Modifier = Modifier ) { - 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)) ) { - Column { - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) - // Command suggestions box - if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { - CommandSuggestionsBox( - suggestions = commandSuggestions, - onSuggestionClick = onCommandSuggestionClick, - modifier = Modifier.fillMaxWidth() - ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + // Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border. + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) + + // Command suggestions box + if (showCommandSuggestions && commandSuggestions.isNotEmpty()) { + CommandSuggestionsBox( + suggestions = commandSuggestions, + onSuggestionClick = onCommandSuggestionClick, + modifier = Modifier.fillMaxWidth() + ) + 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 + // 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 } - // Mention suggestions box - if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) { + } + 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 = mentionSuggestions, + suggestions = displayedMentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f)) + HorizontalDivider(thickness = 1.dp, color = colorScheme.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() - ) } + MessageInput( + value = messageText, + onValueChange = onMessageTextChange, + onSend = onSend, + onSendVoiceNote = onSendVoiceNote, + onSendImageNote = onSendImageNote, + onSendFileNote = onSendFileNote, + selectedPrivatePeer = selectedPrivatePeer, + currentChannel = currentChannel, + nickname = nickname, + showMediaButtons = showMediaButtons, + mentionPeerIdentities = mentionPeerIdentities, + modifier = Modifier.fillMaxWidth() + ) } } -@OptIn(ExperimentalMaterial3Api::class) + +/** + * 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, selectedPrivatePeer: String?, currentChannel: String?, nickname: String, @@ -626,42 +710,48 @@ private fun ChatFloatingHeader( ) { val context = androidx.compose.ui.platform.LocalContext.current val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - - Surface( + + Box( modifier = Modifier .fillMaxWidth() .zIndex(1f) - .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() - } + // 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 + ) { + // No TopAppBar: it 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. Height and edge insets belong to each header variant, so that a conversation + // header rendered here and one rendered in a sheet are laid out identically. + 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..d2ab06d3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -7,9 +7,10 @@ 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 androidx.compose.material3.ColorScheme 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.colorForPeer import java.text.SimpleDateFormat import java.util.* @@ -18,6 +19,27 @@ 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 = 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 + +/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */ +internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha + +/** + * Mention token grammar shared by rendered messages and the composer. + * + * The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the + * same nickname. Keeping one regex prevents the composer from styling only `@name` while the + * rendered transcript styles the complete token. + */ +internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)") + /** * Get RSSI-based color for signal strength visualization */ @@ -32,152 +54,31 @@ 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 + myPeerID: String, + 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 isSelf = message.isFromSelf(currentUserNickname, myPeerID) + val senderColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeer(peerIdentityForMessage(message), palette) + } + 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 ) ) @@ -198,9 +99,9 @@ fun formatTextMessageSender( if (suffix.isNotEmpty()) { builder.pushStyle( SpanStyle( - color = senderColor.copy(alpha = 0.6f), - fontSize = BASE_FONT_SIZE.sp, - fontWeight = senderWeight + color = senderColor.copy(alpha = SUFFIX_ALPHA), + fontSize = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal ) ) builder.append(suffix) @@ -212,10 +113,13 @@ 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 [appendBodyTimestamp]. */ 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( @@ -233,66 +137,159 @@ 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 appendTimestampText( + builder: AnnotatedString.Builder, + message: BitchatMessage, + timeFormatter: SimpleDateFormat +) { + 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, + contentColor: Color, + timeFormatter: SimpleDateFormat, +) { + builder.pushStyle( + SpanStyle( + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemTimeFontSize, + fontWeight = FontWeight.Normal, + ) + ) + appendTimestampText(builder, message, timeFormatter) + 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, + contentColor: Color, + linkColor: Color, + mentionPeerIdentities: Map = emptyMap(), + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, 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, + contentColor = contentColor, + linkColor = linkColor, + mentionPeerIdentities = mentionPeerIdentities, ) + + if (includeTimestamp) { + appendBodyTimestamp(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, + contentColor: Color, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) +): AnnotatedString { + val builder = AnnotatedString.Builder() + builder.pushStyle( + SpanStyle( + color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha), + fontSize = ChatVisualTokens.SystemActionFontSize, + fontWeight = FontWeight.Medium, + ) + ) + builder.append("// ") + builder.append(message.content) + builder.pop() + + appendMutedTimestamp(builder, message, contentColor, 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()) + myPeerID: String, + palette: BitchatPalette, + contentColor: Color, + timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, 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, myPeerID) - val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID) + if (message.sender == "system") { + return formatSystemMessage(message, contentColor, timeFormatter) + } - if (message.sender != "system") { - val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) + if (includeSender) { + val baseColor = if (isSelf) { + palette.accentOrange + } else { + colorForPeer(peerIdentityForMessage(message), palette) + } 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 = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.SemiBold, + ) + ) + builder.append("@") val nicknameStart = builder.length builder.append(truncateNickname(baseName)) val nicknameEnd = builder.length @@ -306,112 +303,23 @@ 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 = ChatVisualTokens.SenderFontSize, + fontWeight = FontWeight.Normal, + ) + ) 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() } + 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 (matches iOS algorithm exactly) - */ -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) 0.50 else 0.70 - val brightness = if (isDark) 0.85 else 0.35 - - 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) */ @@ -430,23 +338,106 @@ fun splitSuffix(name: String): Pair { } /** - * iOS-style content formatting with proper hashtag and mention handling + * Build a case-insensitive mention-token lookup from canonical peer identities. + * + * Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when + * it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention + * as the wrong person. + */ +internal fun buildMentionPeerIdentityMap( + messages: List, + knownPeers: List> = emptyList(), +): Map { + val candidates = linkedMapOf>() + + fun add(displayName: String, identity: PeerIdentity) { + val normalizedName = displayName.trim().removePrefix("@") + if (normalizedName.isEmpty()) return + + val (baseName, suffix) = splitSuffix(normalizedName) + val exactKey = normalizedName.lowercase(Locale.ROOT) + candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity) + + if (suffix.isNotEmpty()) { + val baseKey = baseName.lowercase(Locale.ROOT) + candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity) + } + } + + messages + .asSequence() + .filterNot { it.sender == "system" } + .forEach { add(it.sender, peerIdentityForMessage(it)) } + knownPeers.forEach { (displayName, identity) -> add(displayName, identity) } + + return candidates.mapNotNull { (token, identities) -> + identities.singleOrNull()?.let { token to it } + }.toMap() +} + +internal fun resolveMentionPeerIdentity( + mention: String, + mentionPeerIdentities: Map, +): PeerIdentity? { + val mentionWithoutAt = mention.trim().removePrefix("@") + val baseName = splitSuffix(mentionWithoutAt).first + return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)] + ?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)] +} + +/** + * Resolve the deterministic color for a mention on every surface that displays one. + * + * Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is + * unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID. + */ +internal fun colorForMention( + mention: String, + mentionPeerIdentities: Map, + palette: BitchatPalette, +): Color { + val mentionWithoutAt = mention.trim().removePrefix("@") + val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities) + ?: PeerIdentity.nickname(mentionWithoutAt) + return colorForPeer(identity, palette) +} + +/** + * 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. + * + * Redesign notes: + * - 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 + * 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, + contentColor: Color, + linkColor: Color, + mentionPeerIdentities: Map, ) { - // 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() val hashtagMatches = hashtagPattern.findAll(content).toList() - val mentionMatches = mentionPattern.findAll(content).toList() + val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList() // Combine and sort matches, but exclude hashtags that overlap with mentions val mentionRanges = mentionMatches.map { it.range } @@ -507,28 +498,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 +531,58 @@ 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 { + colorForMention( + mention = mentionWithoutAt, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ) + } + 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 +595,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 +609,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 +622,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/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index 0cfa91ba..f0647e6c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -6,11 +6,12 @@ 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 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( @@ -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/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index c62a39e5..3017b20c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1168,22 +1168,17 @@ class ChatViewModel( } } - // MARK: - iOS-Compatible Color System + // MARK: - Canonical peer identities /** - * Get consistent color for a mesh peer by ID (iOS-compatible) + * Return the stable identity used by every UI surface to color a mesh peer. */ - 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 peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID) /** - * Get consistent color for a Nostr pubkey (iOS-compatible) + * Return the stable identity used by every UI surface to color a Nostr peer. */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) -} + fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex) } 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..34b205af 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,18 +449,28 @@ 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() + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) geohashPeople.mapNotNull { person -> - val displayName = person.displayName - // Exclude self from suggestions - if (displayName.startsWith("${currentNickname}#")) { + val baseName = splitSuffix(person.displayName).first + val hasNicknameCollision = + baseName.lowercase(Locale.ROOT) in duplicateNames + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + // A unique local nickname can be excluded directly. If it collides, the + // nickname alone cannot identify which row is self, so keep the suffixed + // rows rather than accidentally hiding the other user. + if ( + !hasNicknameCollision && + baseName.equals(currentNickname, ignoreCase = true) + ) { null } else { displayName @@ -469,13 +480,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 +542,22 @@ 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(::isUnannouncedNickname) + .filter { nickname -> nickname.startsWith(query, ignoreCase = true) } + .distinctBy { nickname -> nickname.lowercase(Locale.ROOT) } + .sortedWith(String.CASE_INSENSITIVE_ORDER) + .toList() +} 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..0cf88b74 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -1,39 +1,34 @@ package com.bitchat.android.ui -import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* 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 androidx.compose.material.icons.filled.Email +import android.util.Log +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.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.BASE_FONT_SIZE -import java.util.* 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.ui.theme.colorForPeer 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 ) @@ -43,116 +38,227 @@ fun GeohashPeopleList( onTapPerson: () -> Unit, 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 teleportedGeo by viewModel.teleportedGeo.collectAsStateWithLifecycle() 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) - ) - } - - if (geohashPeople.isEmpty()) { - // 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) - ) - } else { - // Get current geohash identity for "me" detection - 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 - } - } - - // 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 - } - } - } - // 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) - counts[b] = (counts[b] ?: 0) + 1 + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + 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 } - counts } - - val firstID = orderedPeople.firstOrNull()?.id - - orderedPeople.forEach { person -> + 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 duplicateBaseNames = remember(displayedPeople) { + duplicateGeohashBaseNames(displayedPeople) + } + + Column(modifier = modifier) { + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, displayedPeople.size) + ) + + if (displayedPeople.isEmpty()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Text( + text = stringResource(R.string.nobody_around), + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + color = palette.textTertiary, + modifier = Modifier.padding( + horizontal = SheetRowHorizontal, + vertical = SheetRowVertical + ) + ) + } + } else { + @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, - isFirst = person.id == firstID, - 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[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1, + showHashSuffix = splitSuffix(person.displayName) + .first + .lowercase(Locale.ROOT) in duplicateBaseNames, onTap = { - if (person.id != myHex) { - // TODO: Re-enable when NIP-17 geohash DM issues are fixed - // Start geohash DM (iOS-compatible) + if (!isMe) { viewModel.startGeohashDM(person.id) onTapPerson() } } ) } + + if (sections.onLocation.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_on_location)) + PeopleCard( + people = sections.onLocation, + row = { personRow(it) } + ) + } + + if (sections.teleportedIn.isNotEmpty()) { + AboutSectionLabel(text = stringResource(R.string.section_teleported_in)) + PeopleCard( + people = sections.teleportedIn, + row = { personRow(it) } + ) + } + } + } +} + +internal data class GeohashPeopleSections( + val onLocation: List, + val teleportedIn: List +) + +/** + * Names that require a short identity suffix, calculated across both people sections. + * + * Matching is case-insensitive to mirror geohash chat's nickname collision handling. + */ +internal fun duplicateGeohashBaseNames(people: List): Set = + people + .groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) } + .eachCount() + .filterValues { it > 1 } + .keys + +/** + * The same `#abcd` disambiguator used by geohash chat. + * + * Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr + * public key when a collision exists. Preserve an already-announced suffix for compatibility. + */ +internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String { + if (!showHashSuffix) return "" + val announcedSuffix = splitSuffix(person.displayName).second + return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" } +} + +internal fun disambiguatedGeohashDisplayName( + person: GeoPerson, + duplicateBaseNames: Set, +): String { + val baseName = splitSuffix(person.displayName).first + val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames + return baseName + geohashIdentitySuffix(person, showSuffix) +} + +/** + * 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 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 uncapped card of people. The enclosing sheet owns scrolling. */ +@Composable +private fun PeopleCard( + people: List, + row: @Composable (GeoPerson) -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + AnimatedRowColumn(items = people, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + row(person) + } } } } @@ -160,113 +266,97 @@ fun GeohashPeopleList( @Composable private fun GeohashPersonItem( person: GeoPerson, - isFirst: Boolean, isMe: Boolean, hasUnreadDM: Boolean, isTeleported: Boolean, - isMyTeleported: Boolean, - nickname: String, - colorScheme: ColorScheme, viewModel: ChatViewModel, showHashSuffix: Boolean, onTap: () -> Unit ) { + val palette = LocalBitchatPalette.current + + val statusIconRes = + if (isTeleported) R.drawable.ic_spec_teleport + else R.drawable.ic_spec_on_location_person + + val (baseNameRaw, _) = splitSuffix(person.displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = geohashIdentitySuffix(person, showHashSuffix) + val assignedColor = colorForPeer( + viewModel.peerIdentityForNostrPubkey(person.id), + palette + ) + val baseColor = if (isMe) palette.accentOrange else assignedColor + Row( modifier = Modifier .fillMaxWidth() - .clickable { onTap() } - .padding(horizontal = 24.dp, vertical = 4.dp) - .padding(top = if (isFirst) 10.dp else 0.dp), + // 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), 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(12.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } 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 + 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( + painter = painterResource(statusIconRes), + contentDescription = if (isTeleported) { + stringResource(R.string.cd_teleported) + } else { + stringResource(R.string.section_on_location) + }, + modifier = Modifier.size(22.dp), + tint = baseColor + ) } - - // Use appropriate Material icon (closest match to iOS SF Symbols) - 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(12.dp), - tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent - ) } - - 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 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 - + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - // 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 = BitchatFontFamily, + 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 = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + 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 = BitchatFontFamily, + fontSize = 14.sp, color = baseColor ) } } - - Spacer(modifier = Modifier.width(8.dp)) } } - - 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..9565c384 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 @@ -30,14 +30,14 @@ 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.FontFamily 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 +import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import androidx.core.view.updateLayoutParams import com.bitchat.android.geohash.Geohash @@ -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()) { @@ -186,7 +184,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 +209,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 +275,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/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 9d949e54..8a2e31d8 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -269,29 +269,27 @@ 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 = locationChannelManager?.teleported?.value - ?: 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, - liveLocationToken = liveLocationToken - ) - } 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 = locationChannelManager?.teleported?.value + ?: 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, + liveLocationToken = liveLocationToken + ) } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") } @@ -438,10 +436,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 peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + PeerIdentity.nostr(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 91555b38..7302379b 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -1,27 +1,55 @@ package com.bitchat.android.ui + +import com.bitchat.android.ui.theme.BitchatFontFamily // [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.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.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 +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 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 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 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 @@ -36,8 +64,10 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.FocusRequester 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.BitchatPalette +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,41 +83,28 @@ 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 { - var lastIndex = 0 - - slashCommandRegex.findAll(text.text).forEach { match -> - // Add text before the match - if (match.range.first > lastIndex) { - append(text.text.substring(lastIndex, match.range.first)) - } - - // Add the styled slash command - withStyle( - style = SpanStyle( - color = Color(0xFF00FF7F), // Bright green - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - background = Color(0xFF2D2D2D) // Dark gray background - ) - ) { - append(match.value) - } - - lastIndex = match.range.last + 1 - } - - // Add remaining text - if (lastIndex < text.text.length) { - append(text.text.substring(lastIndex)) - } + val builder = AnnotatedString.Builder(text) + slashCommandRegex.findAll(text.text).forEach { match -> + builder.addStyle( + style = SpanStyle( + color = commandColor, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + background = commandBackground + ), + start = match.range.first, + end = match.range.last + 1, + ) } return TransformedText( - text = annotatedString, + text = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity ) } @@ -97,40 +114,56 @@ 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 mentionPeerIdentities: Map, + private val palette: BitchatPalette, +) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val mentionRegex = Regex("@([a-zA-Z0-9_]+)") - val annotatedString = buildAnnotatedString { - var lastIndex = 0 - - mentionRegex.findAll(text.text).forEach { match -> - // Add text before the match - if (match.range.first > lastIndex) { - append(text.text.substring(lastIndex, match.range.first)) - } - - // Add the styled mention - withStyle( + val builder = AnnotatedString.Builder(text) + + MENTION_TOKEN_REGEX.findAll(text.text).forEach { match -> + val start = match.range.first + val end = match.range.last + 1 + val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 } + val suffixStart = suffixOffset?.let(start::plus) ?: end + val mentionColor = colorForMention( + mention = match.value, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ) + + // Keep the whole token on one continuous color-derived chip. + builder.addStyle( + style = SpanStyle( + background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA), + ), + start = start, + end = end, + ) + builder.addStyle( + style = SpanStyle( + color = mentionColor, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + ), + start = start, + end = suffixStart, + ) + if (suffixStart < end) { + builder.addStyle( style = SpanStyle( - color = Color(0xFFFF9500), // Orange - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold - ) - ) { - append(match.value) - } - - lastIndex = match.range.last + 1 - } - - // Add remaining text - if (lastIndex < text.text.length) { - append(text.text.substring(lastIndex)) + color = mentionColor.copy(alpha = SUFFIX_ALPHA), + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.SemiBold, + ), + start = suffixStart, + end = end, + ) } } return TransformedText( - text = annotatedString, + text = builder.toAnnotatedString(), offsetMapping = OffsetMapping.Identity ) } @@ -159,6 +192,107 @@ class CombinedVisualTransformation(private val transformations: List Unit +) { + val palette = LocalBitchatPalette.current + 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 + // loudest thing on the screen; at 20% it still reads as "armed" without competing. + targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerButtonContainer" + ) + val tint by animateColorAsState( + targetValue = if (isActive) accent else colorScheme.onSurfaceVariant, + 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( value: TextFieldValue, @@ -171,192 +305,276 @@ fun MessageInput( currentChannel: String?, nickname: String, showMediaButtons: Boolean, + mentionPeerIdentities: Map = emptyMap(), modifier: Modifier = Modifier ) { + val palette = LocalBitchatPalette.current val colorScheme = MaterialTheme.colorScheme 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 is the one state worth shouting about, so it overrides focus. + val borderColor by animateColorAsState( + targetValue = when { + isRecording -> colorScheme.error.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. Slightly translucent so the messages scrolling underneath stay faintly visible. + val containerColor by animateColorAsState( + targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface) + .copy(alpha = ComposerFillAlpha), + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "composerContainer" + ) + Row( - modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom ) { - // Text input with placeholder OR visualizer when recording - Box( - modifier = Modifier.weight(1f) - ) { - // Always keep the text field mounted to retain focus and avoid IME collapse - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = colorScheme.primary, - fontFamily = FontFamily.Monospace - ), - cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { - if (hasText) onSend() // Only send if there's text - }), - visualTransformation = CombinedVisualTransformation( - listOf(SlashCommandVisualTransformation(), MentionVisualTransformation()) - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .onFocusChanged { focusState -> - isFocused.value = focusState.isFocused - } - ) - - // 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( - fontFamily = FontFamily.Monospace - ), - color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey - modifier = Modifier.fillMaxWidth() + // MARK: - The pill. Field and action buttons are one visual object. + Row( + modifier = Modifier + .weight(1f) + .heightIn(min = ComposerMinHeight) + // 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 = 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.onSurface, + fontFamily = BitchatFontFamily + ), + cursorBrush = SolidColor( + if (isRecording) Color.Transparent else colorScheme.onSurface + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { + if (hasText) onSend() + }), + // Cap the growth so a pasted wall of text cannot swallow the message list. + maxLines = 6, + visualTransformation = remember( + palette, + colorScheme.primary, + mentionPeerIdentities, + ) { + CombinedVisualTransformation( + listOf( + SlashCommandVisualTransformation( + commandColor = colorScheme.primary, + commandBackground = colorScheme.primary.copy(alpha = 0.14f), + ), + MentionVisualTransformation( + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ), + ) + ) + }, + 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 + // 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 = 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 = BitchatFontFamily + ), + color = palette.textTertiary, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .alpha(placeholderAlpha) ) } - } - } - - 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) - // Ensure latest values are used when finishing 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( + modifier = Modifier + .fillMaxWidth() + .alpha(waveformAlpha), + verticalAlignment = Alignment.CenterVertically + ) { + RealtimeScrollingWaveform( + modifier = Modifier.weight(1f).height(22.dp), + amplitudeNorm = normalizeAmplitudeSample(amplitude) + ) + Spacer(Modifier.width(12.dp)) + val secs = (elapsedMs / 1000).toInt() + val maxSecs = 10 // 10 second max recording time + Text( + text = String.format( + "%02d:%02d / %02d:%02d", + secs / 60, secs % 60, maxSecs / 60, maxSecs % 60 + ), + fontFamily = BitchatFontFamily, + color = colorScheme.error, + fontSize = (BASE_FONT_SIZE - 4).sp + ) + } + } + } + + // 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) { - // 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) - } - ) - } - } - - Spacer(Modifier.width(1.dp)) - - VoiceRecordButton( - backgroundColor = bg, - 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) {} + 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) } }, - 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) {} - } - } - // BLE path (private or public) — use latest values to avoid stale captures - latestOnSendVoiceNote.value( - latestSelectedPeer.value, - latestChannel.value, - path + 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 { - // 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 { + 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( + 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 + ) + }, + // 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 { - Color.White // White arrow on dark green in light theme + // 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) } - ) + } } } } @@ -365,6 +583,42 @@ fun MessageInput( // Auto-stop handled inside VoiceRecordButton } +/** + * 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( + isAccented: Boolean, + onSend: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + val palette = LocalBitchatPalette.current + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + ComposerActionSurface( + isActive = enabled, + isPressed = isPressed, + // Private chats and channels keep their orange identity, disc and glyph together. + activeColor = if (isAccented) palette.accentOrange else colorScheme.primary, + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onSend() } + ) { tint -> + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = stringResource(id = R.string.send_message), + modifier = Modifier.size(ComposerIconSize), + tint = tint + ) + } +} + @Composable fun CommandSuggestionsBox( suggestions: List, @@ -377,8 +631,8 @@ fun CommandSuggestionsBox( modifier = modifier .verticalScroll(rememberScrollState()) .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp)) + .padding(vertical = 6.dp) ) { suggestions.forEach { suggestion: CommandSuggestion -> CommandSuggestionItem( @@ -395,13 +649,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) ) { @@ -415,11 +670,11 @@ fun CommandSuggestionItem( Text( text = allCommands.joinToString(", "), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Medium ), color = colorScheme.primary, - fontSize = (BASE_FONT_SIZE - 4).sp + fontSize = (BASE_FONT_SIZE - 2).sp ) // Show syntax if any @@ -427,10 +682,10 @@ fun CommandSuggestionItem( Text( text = syntax, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.8f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = colorScheme.onSurfaceVariant, + fontSize = (BASE_FONT_SIZE - 4).sp ) } @@ -438,34 +693,64 @@ fun CommandSuggestionItem( Text( text = suggestion.description, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - 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 ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun MentionSuggestionsBox( suggestions: List, + mentionPeerIdentities: Map, onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - - Column( + val palette = LocalBitchatPalette.current + + LazyColumn( modifier = modifier + .heightIn(max = MentionSuggestionsMaxHeight) + .animateContentSize( + animationSpec = tween( + BitchatMotion.STANDARD_MS, + easing = FastOutSlowInEasing + ) + ) + .clip(MentionSuggestionsShape) .background(colorScheme.surface) - .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp)) - .padding(vertical = 8.dp) + .border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape), + contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding) ) { - suggestions.forEach { suggestion: String -> + items( + items = suggestions, + key = { suggestion -> suggestion.lowercase() } + ) { suggestion -> MentionSuggestionItem( suggestion = suggestion, - onClick = { onSuggestionClick(suggestion) } + userColor = colorForMention( + mention = suggestion, + mentionPeerIdentities = mentionPeerIdentities, + palette = palette, + ), + 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) + ) ) } } @@ -474,37 +759,76 @@ fun MentionSuggestionsBox( @Composable fun MentionSuggestionItem( suggestion: String, - onClick: () -> Unit + userColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier ) { - val colorScheme = MaterialTheme.colorScheme - + val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressedBackground by animateColorAsState( + targetValue = if (isPressed) { + userColor.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() - .clickable { onClick() } - .padding(horizontal = 12.dp, vertical = 3.dp) - .background(Color.Gray.copy(alpha = 0.1f)), + .height(MentionSuggestionRowHeight) + .scale(pressedScale) + .background(pressedBackground) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.mention_suggestion_at, suggestion), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold ), - color = Color(0xFFFF9500), // Orange like mentions - fontSize = (BASE_FONT_SIZE - 4).sp + color = userColor, + 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), style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), - color = colorScheme.onSurface.copy(alpha = 0.7f), - fontSize = (BASE_FONT_SIZE - 5).sp + color = palette.textTertiary, + 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/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt index 4896e345..57710171 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 @@ -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 @@ -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 ) } } @@ -110,7 +107,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 +118,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 2480ead0..19156485 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,53 +1,105 @@ 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 +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.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.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 -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.material3.* import androidx.compose.runtime.* 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.text.font.FontFamily +import androidx.compose.ui.platform.LocalDensity +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 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.BitchatFontFamily import com.bitchat.android.nostr.NearbyNotesController import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.LifecycleResumeEffect +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.LocalSheetDismiss +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 com.bitchat.android.wifiaware.WifiAwareController +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull /** - * 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 = 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 = 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. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -61,7 +113,6 @@ 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 notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle() @@ -71,25 +122,17 @@ fun LocationChannelsSheet( 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() + val wifiAwareEnabled by WifiAwareController.enabled.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 { @@ -97,7 +140,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" ) @@ -113,11 +157,20 @@ 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 = colorScheme.primary + val standardBlue = colorScheme.secondary + + 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 if (isPresented) { BitchatBottomSheet( @@ -125,389 +178,413 @@ 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() + } + } + Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp) + // 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) ) { - // 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), - modifier = Modifier - .padding(horizontal = 24.dp) + // Mesh section: icon + title header, offline subtitle, then selection card + item(key = "mesh_card") { + Column { + SheetIconSectionHeader( + 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) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + ChannelOptionRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + if (wifiAwareEnabled) { + R.string.location_bluetooth_wifi_subtitle + } else { + R.string.location_bluetooth_subtitle + }, + meshRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + participantCount = meshCount(viewModel), + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) + onDismiss() + } + ) + } + } + } + + item(key = "location_channels_header") { + SheetIconSectionHeader( + 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) ) } - // Permission controls if services enabled - if (locationServicesEnabled) { + 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 = colorScheme.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 = colorScheme.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.selectManual( + channel = channel, + teleported = !appLocationEnabled || + availableChannels.isEmpty() || + !inRegional + ) + onDismiss() + } + ) + LaunchedEffect(gh) { + bookmarksStore.resolveNameIfNeeded(gh) + } + } + } + } + } + } + } + + 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 = 8.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 = Color.Red.copy(alpha = 0.8f) - ) - 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 + Text( + text = stringResource(R.string.location_permission_denied), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.error + ) + TextButton( + onClick = { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts( + "package", + context.packageName, + null ) } - } - } - 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, - 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 } - 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), - 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), - ) - } - }, - onClick = { - if (locationManager.selectNearby(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 = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - Text( - text = stringResource(R.string.finding_nearby_channels), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - } - - // 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) - ) - } - 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), - titleColor = null, - titleBold = participantCount > 0, - trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(gh) }) { - Icon( - imageVector = Icons.Filled.Bookmark, - contentDescription = stringResource(R.string.cd_remove_bookmark), - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - ) - } - }, - onClick = { - val inRegional = availableChannels.any { it.geohash == gh } - locationManager.selectManual( - channel = channel, - teleported = !appLocationEnabled || - availableChannels.isEmpty() || - !inRegional - ) - onDismiss() - } - ) - LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } - } - } - - // Custom geohash teleport (iOS-style inline form) - item(key = "custom_geohash") { - Surface( - color = Color.Transparent, - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 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 = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - - 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 + context.startActivity(intent) }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface - ), - 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 = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - ) - } - 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 = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - - val isValid = validateGeohash(normalized) - - // iOS-style teleport button - Button( - onClick = { - if (isValid) { - val level = levelForLength(normalized.length) - val channel = GeohashChannel(level = level, geohash = normalized) - locationManager.selectManual(channel) - onDismiss() - } else { - customError = context.getString(R.string.invalid_geohash) - } - }, - enabled = isValid, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ) + contentPadding = PaddingValues(0.dp) ) { - Row( - 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 - ) - } + Text( + text = stringResource(R.string.open_settings), + fontSize = 12.sp, + fontFamily = BitchatFontFamily + ) } } } } - // Error message for custom geohash - if (customError != null) { - item(key = "geohash_error") { - Text( - text = customError!!, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = Color.Red, + // Nearby location channels and the control for teleporting somewhere new. + item(key = "channels_card") { + Column { + AboutSectionLabel( + text = stringResource(R.string.location_channels_nearby) + ) + Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - } - } - - // Location services toggle button - item(key = "location_toggle") { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 8.dp) - ) { - Button( - onClick = { - if (appLocationEnabled) { - locationManager.disableLocationServices() - } else { - locationManager.enableLocationServices() - } - }, - colors = ButtonDefaults.buttonColors( - containerColor = if (appLocationEnabled) { - Color.Red.copy(alpha = 0.08f) - } else { - standardGreen.copy(alpha = 0.12f) - }, - contentColor = if (appLocationEnabled) { - Color(0xFFBF1A1A) - } else { - standardGreen - } - ), - modifier = Modifier.fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape ) { + Column { + if (locationServicesEnabled) { + if (nearbyChannels.isNotEmpty()) { + nearbyChannels.forEachIndexed { index, channel -> + if (index > 0) SheetCardDivider() + 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 = { + if (locationManager.selectNearby(channel)) { + onDismiss() + } + } + ) + } + SheetCardDivider() + } else if (showNearbyLoading) { + ChannelLoadingRow() + SheetCardDivider() + } + } + + 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() } + }, + 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.selectManual(channel) + onDismiss() + } else { + customError = context.getString(R.string.invalid_geohash) + } + } + ) + } + } + + 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 = if (appLocationEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services), + text = shownError, fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily, + color = colorScheme.error, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) ) } } } + + item(key = "tor_routing") { + val torProvider = remember { ArtiTorManager.getInstance() } + val torAvailable = remember { torProvider.isTorAvailable() } + var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) } + + Column { + AboutSectionLabel(text = stringResource(R.string.about_network)) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding), + color = colorScheme.surface, + shape = AboutCardShape + ) { + 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) + } + } + ) + } + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary, + modifier = Modifier.padding( + start = AboutHorizontalPadding + ChannelRowHorizontal, + top = 8.dp + ) + ) + } + } + } + + item(key = "location_toggle") { + SheetDestructiveButton( + text = if (appLocationEnabled) { + stringResource(R.string.disable_location_services) + } else { + stringResource(R.string.enable_location_services) + }, + isDestructive = appLocationEnabled, + onClick = { + if (appLocationEnabled) { + locationManager.disableLocationServices() + } else { + locationManager.enableLocationServices() + } + }, + modifier = Modifier.padding( + start = AboutHorizontalPadding, + end = AboutHorizontalPadding, + top = 24.dp + ) + ) + } } - // TopBar (animated) BitchatSheetTopBar( onClose = onDismiss, modifier = modifier.align(Alignment.TopCenter), @@ -521,7 +598,6 @@ fun LocationChannelsSheet( } } - // Lifecycle management: when presented, manage location updates LifecycleResumeEffect(isPresented, appLocationEnabled, systemLocationEnabled) { if (isPresented) { val currentPermission = locationManager.syncPermissionState() @@ -529,6 +605,9 @@ fun LocationChannelsSheet( systemLocationEnabled && currentPermission == LocationChannelManager.PermissionState.AUTHORIZED ) { + // Retain the redesign branch's immediate refresh when the sheet resumes while + // honoring main's independent app/system privacy gates. + locationManager.enableLocationChannels() locationManager.beginLiveRefresh() } } @@ -561,104 +640,388 @@ fun LocationChannelsSheet( } } - // Ensure cleanup when the composable is destroyed (e.g. removed from parent composition) DisposableEffect(Unit) { - onDispose { - viewModel.endGeohashSampling() + onDispose { viewModel.endGeohashSampling() } + } +} + +/** + * 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, + participantCount: Int, + titleColor: Color? = null, + titleBold: Boolean = false, + leadingIcon: ImageVector? = null, + trailingContent: (@Composable (() -> Unit))? = null, + onClick: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val (baseTitle, countSuffix) = splitTitleAndCount(title) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center + ) { + when { + isSelected -> { + Box( + modifier = Modifier + .size(ChannelSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } + leadingIcon != null -> { + Icon( + imageVector = leadingIcon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(22.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 + ) { + Text( + text = baseTitle, + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium, + color = titleColor ?: colorScheme.onSurface + ) + countSuffix?.let { count -> + AnimatedCountLabel( + count = participantCount, + text = count, + fontSize = 11.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } + } + Text( + text = subtitle, + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + lineHeight = 17.sp, + color = colorScheme.onSurfaceVariant + ) + } + + if (trailingContent != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailingContent() } } } @Composable -private fun ChannelRow( - title: String, - subtitle: String, - isSelected: Boolean, - titleColor: Color? = null, - titleBold: Boolean = false, - trailingContent: (@Composable (() -> Unit))? = null, +private fun ChannelBookmarkButton( + bookmarked: Boolean, onClick: () -> Unit ) { - // iOS-style list row (plain button, no card background) - Surface( - onClick = onClick, - color = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + 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 + ), + tint = if (bookmarked) colorScheme.primary else colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } +} + +@Composable +private fun ChannelLoadingRow() { + val colorScheme = MaterialTheme.colorScheme + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical), + horizontalArrangement = Arrangement.spacedBy(ChannelLeadingGutter), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box( + modifier = Modifier.size(ChannelLeadingSlot), + contentAlignment = Alignment.Center ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - // Split title to handle count part with smaller font (iOS style) - val (baseTitle, countSuffix) = splitTitleAndCount(title) + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = colorScheme.onSurfaceVariant + ) + } + Text( + text = stringResource(R.string.finding_nearby_channels), + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.onSurfaceVariant + ) + } +} - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = baseTitle, - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal, - color = titleColor ?: MaterialTheme.colorScheme.onSurface - ) +/** + * 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 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 + // 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 { + colorScheme.surfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportContainer" + ) + val teleportInteraction = remember { MutableInteractionSource() } + val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f) - countSuffix?.let { count -> - Text( - text = count, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .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 = BitchatFontFamily, + color = palette.textTertiary + ) + + Spacer(modifier = Modifier.width(4.dp)) + + BasicTextField( + value = customGeohash, + onValueChange = onGeohashChange, + textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = colorScheme.primary + ), + cursorBrush = SolidColor(colorScheme.primary), + singleLine = true, + modifier = Modifier + .weight(1f) + .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() } - } - - Text( - text = subtitle, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - 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) + }, + decorationBox = { inner -> + if (customGeohash.isEmpty()) { + Text( + text = stringResource(R.string.geohash_placeholder), + fontSize = 14.sp, + fontFamily = BitchatFontFamily, + color = palette.textTertiary ) } - - if (trailingContent != null) { - trailingContent() - } + inner() } + ) + + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .pressScaleClickable(onClick = onOpenMap), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = stringResource(R.string.cd_open_map), + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + + Surface( + onClick = onTeleport, + enabled = isValid, + shape = RoundedCornerShape(8.dp), + color = teleportContainer, + interactionSource = teleportInteraction, + modifier = Modifier.scale(teleportScale) + ) { + Text( + text = stringResource(R.string.teleport).uppercase(), + fontSize = 11.sp, + letterSpacing = 0.8.sp, + fontWeight = FontWeight.Medium, + fontFamily = BitchatFontFamily, + color = teleportColor, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp) + ) } } } -// MARK: - Helper Functions (matching iOS implementation) +/** 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 = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (enabled) colorScheme.onSurface else palette.textTertiary + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + fontFamily = BitchatFontFamily, + fontSize = 12.sp, + lineHeight = 17.sp, + color = if (enabled) colorScheme.onSurfaceVariant 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 = colorScheme.primary, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = colorScheme.surfaceVariant + ) + ) + } +} + +// 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) { - val prefix = title.substring(0, lastBracketIndex).trim() - val suffix = title.substring(lastBracketIndex) - Pair(prefix, suffix) + val count = title.substring(lastBracketIndex + 1).removeSuffix("]") + Pair(title.substring(0, lastBracketIndex).trim(), count.ifEmpty { null }) } else { Pair(title, null) } @@ -667,56 +1030,48 @@ 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_label) - return "$meshLabel [$peopleText]" + val ctx = LocalContext.current + val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount) + val meshLabel = stringResource(R.string.mesh_title) + return "$meshLabel$TITLE_COUNT_SEP$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]" + return "$levelName$TITLE_COUNT_SEP$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]" + return "#$geohash$TITLE_COUNT_SEP$peopleText" } private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean { @@ -726,6 +1081,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() @@ -739,13 +1112,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 @@ -758,10 +1130,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 { @@ -772,11 +1141,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 meshRangeString(): String = "~10–50m" -private fun formattedNamePrefix(level: GeohashChannelLevel): String { - return "~" -} +private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~" 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..489ddaf4 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -1,19 +1,21 @@ package com.bitchat.android.ui +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.foundation.shape.CircleShape 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.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +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 @@ -21,9 +23,9 @@ import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager /** - * 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 + * 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( @@ -33,18 +35,15 @@ 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) } 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 @@ -52,15 +51,22 @@ 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(24.dp) + val contentDescription = stringResource(R.string.cd_location_notes) + val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant + val torVisual = rememberTorConnectionVisual(normal = normalTint) + + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), + contentAlignment = Alignment.Center ) { - 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 + TorAwareHeaderIcon( + 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/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 6a224cf2..f3c49480 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -1,16 +1,15 @@ 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 -import androidx.compose.foundation.isSystemInDarkTheme 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.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.getValue @@ -18,9 +17,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 @@ -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() } @@ -251,7 +248,7 @@ private fun LocationNotesHeader( if (name.isNotEmpty()) { Text( text = name, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp, color = accentGreen ) @@ -262,7 +259,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 +269,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 +300,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 +309,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 +321,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 +340,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 +348,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 +382,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 +401,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 +409,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 +437,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 +445,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) @@ -468,7 +465,6 @@ private fun LocationNotesInputSection( nickname: String?, onSend: () -> Unit ) { - val isDark = isSystemInDarkTheme() val colorScheme = MaterialTheme.colorScheme Column( @@ -482,7 +478,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 +502,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 +519,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() @@ -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/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt deleted file mode 100644 index 4ca8674f..00000000 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ /dev/null @@ -1,179 +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, -) { - AnimatedMessageDisplay( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onMessageLongPress = onMessageLongPress, - modifier = modifier, - ) -} - -/** - * 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, -) { - 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, - 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..d9a8d035 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1,26 +1,35 @@ 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.ui.theme.BitchatFontFamily 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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -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.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.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -29,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.favorites.FavoriteRelationship @@ -36,6 +46,9 @@ 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.ui.theme.colorForPeer import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -68,6 +81,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() } @@ -98,74 +113,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) ) { + val peopleCount = when (selectedLocationChannel) { + is ChannelID.Location -> geohashPeopleCount + else -> connectedPeers.count { it != viewModel.myPeerID } + } + // 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) - ) - } - - 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( + iconRes = R.drawable.ic_spec_chat_bubbles, + title = stringResource(R.string.channels), + modifier = Modifier.padding(top = 8.dp) + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = MaterialTheme.colorScheme.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, @@ -173,6 +197,7 @@ fun MeshPeerListSheet( colorScheme = colorScheme, selectedPrivatePeer = selectedPrivatePeer, wifiAwarePeerIDs = wifiAwarePeerIDs, + peopleCount = peopleCount, viewModel = viewModel, onPrivateChatStart = { peerID -> viewModel.showPrivateChatSheet(peerID) @@ -194,13 +219,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 = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) ) } } @@ -213,6 +238,9 @@ fun MeshPeerListSheet( } } +/** Icon size for trailing actions on peer rows (matches settings glyph scale). */ +private val PeerRowIconSize = 22.dp + @Composable private fun ChannelRow( channel: String, @@ -222,59 +250,52 @@ private fun ChannelRow( onChannelClick: () -> Unit, onLeaveChannel: () -> Unit, ) { - Surface( - onClick = onChannelClick, - color = if (isSelected) { - colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - Color.Transparent - }, - shape = MaterialTheme.shapes.medium, + val palette = LocalBitchatPalette.current + + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .clickable(onClick = onChannelClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.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 - ) - } - - 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 + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(colorScheme.primary, CircleShape) ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Leave channel button - CloseButton( - onClick = onLeaveChannel, + } else if (unreadCount > 0) { + UnreadBadge(count = unreadCount, colorScheme = colorScheme) + } else { + Text( + text = "#", + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = palette.textTertiary ) } } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Text( + text = channel, + fontFamily = BitchatFontFamily, + fontSize = 14.sp, + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + CloseButton(onClick = onLeaveChannel) } } @@ -290,6 +311,7 @@ fun PeopleSection( colorScheme: ColorScheme, selectedPrivatePeer: String?, wifiAwarePeerIDs: Set = emptySet(), + peopleCount: Int = 0, viewModel: ChatViewModel, onPrivateChatStart: (String) -> Unit ) { @@ -298,31 +320,34 @@ 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) + SheetIconSectionHeader( + iconRes = R.drawable.ic_spec_people, + title = stringResource(R.string.people_count_title, peopleCount) ) - 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), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 40.dp, vertical = 12.dp) - ) - } + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = colorScheme.surface, + shape = AboutCardShape + ) { + Column { + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + fontFamily = BitchatFontFamily, + 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() @@ -426,7 +451,21 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } - sortedPeers.forEach { peerID -> + // 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() + + 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 @@ -446,7 +485,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, @@ -468,12 +506,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 + } 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) @@ -518,6 +556,10 @@ fun PeopleSection( showHashSuffix = showHash ) } + } + } + } + } } } @@ -547,143 +589,123 @@ 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 = colorForPeer( + viewModel.peerIdentityForMeshPeer(peerID), + palette + ) + 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, + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 2.dp) + .clickable(onClick = onItemClick) + .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.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) { - // 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 - ) - } 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 - ) - } else if (!isDirect && isFavorite) { - // Offline favorited user: show outlined circle icon - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(R.string.cd_offline_favorite), - modifier = Modifier.size(16.dp), - tint = Color.Gray - ) - } 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(16.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - - // Display name with iOS-style color and hashtag suffix support - Row(verticalAlignment = Alignment.CenterVertically) { - // 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 - ), - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - 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) - ) - } - - if (isWifiAware && hasUnreadDM) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(13.dp), - tint = colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - } - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) + if (isSelected) { + Box( + modifier = Modifier + .size(SheetRowSelectedDot) + .background(colorScheme.primary, CircleShape) + ) + } else if (hasUnreadDM) { Icon( - imageVector = Icons.Filled.Verified, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = Color(0xFF32D74B) // iOS Green + painter = painterResource(R.drawable.ic_spec_envelope), + contentDescription = stringResource(R.string.cd_unread_message), + modifier = Modifier.size(PeerRowIconSize), + tint = palette.accentOrange + ) + } else if (showNostrGlobe) { + Icon( + painter = painterResource(R.drawable.ic_spec_globe), + 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( + painter = painterResource( + conversationTransportIcon( + isReachedOverInternet = false, + isWifiAware = isWifiAware, + isDirect = isDirect + ) + ), + contentDescription = when { + isWifiAware -> "Direct Wi-Fi Aware" + isDirect -> "Direct Bluetooth" + else -> "Routed" + }, + modifier = Modifier.size(PeerRowIconSize), + tint = colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.width(SheetRowLeadingGutter)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = baseName, + fontFamily = BitchatFontFamily, + 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 = BitchatFontFamily, + 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(32.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) - ) - } + if (isVerified) { + Icon( + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(16.dp), + tint = colorScheme.primary + ) } } + + Box( + modifier = Modifier + .size(36.dp) + .clickable(onClick = onToggleFavorite), + contentAlignment = Alignment.Center + ) { + Icon( + 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 + ) + } } } @@ -696,25 +718,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 = BitchatFontFamily, + color = Color.Black ) } } @@ -804,7 +842,7 @@ fun PrivateChatSheet( val name = if (fullPubkey.isNotEmpty()) { viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh) } else { - peerNicknames[peerID] ?: "unknown" + peerNicknames[peerID] ?: "Unknown" } "#$gh/@$name" } else { @@ -826,12 +864,7 @@ fun PrivateChatSheet( viewModel.isPeerVerified(peerID, verifiedFingerprints) } - val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) { - Modifier.clickable { viewModel.showSecurityVerificationSheet() } - } else { - Modifier - } - + val palette = LocalBitchatPalette.current val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) @@ -845,9 +878,9 @@ fun PrivateChatSheet( Column( modifier = Modifier.fillMaxSize() ) { - Spacer(modifier = Modifier.height(64.dp)) + Spacer(modifier = Modifier.height(ChatHeaderHeight)) - HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant) // Messages list var forceScrollToBottom by remember { mutableStateOf(false) } @@ -858,6 +891,7 @@ fun PrivateChatSheet( currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, modifier = Modifier.weight(1f), + conversationKey = "dm:$peerID", forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, onNicknameClick = { /* handle mention */ }, @@ -866,9 +900,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( @@ -913,112 +946,97 @@ fun PrivateChatSheet( ) } - // TopBar (fixed at top, iOS-style) - BitchatSheetCenterTopBar( - onClose = onDismiss, + // Header. Built from the same tokens as the main chat header rather than a + // TopAppBar, so moving between the timeline and a conversation does not shift the + // bar's height, insets or type. + Surface( modifier = Modifier.align(Alignment.TopCenter), - navigationIcon = { - IconButton( - onClick = onDismiss, - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp) - .size(32.dp) + color = colorScheme.background + ) { + ConversationHeader( + leadingIconRes = conversationTransportIcon( + isReachedOverInternet = isNostrPeer || isNostrReachableFavorite, + isWifiAware = isWifiAware, + isDirect = isDirect + ), + leadingIconTint = colorScheme.primary, + leadingContentDescription = when { + isNostrPeer || isNostrReachableFavorite -> + stringResource(R.string.cd_nostr_reachable) + else -> null + }, + title = titleText + ) { + ConversationHeaderAction( + onClick = { viewModel.toggleFavorite(peerID) }, + contentDescription = if (isFavorite) { + stringResource(R.string.cd_remove_favorite) + } else { + stringResource(R.string.cd_add_favorite) + } ) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.chat_back), - tint = colorScheme.onSurface + painter = painterResource( + if (isFavorite) { + R.drawable.ic_spec_star_filled + } else { + R.drawable.ic_spec_star + } + ), + contentDescription = null, + modifier = Modifier.size(HeaderIconSize), + tint = if (isFavorite) { + palette.accentOrange + } else { + colorScheme.onSurfaceVariant + } ) } - }, - title = { - // Center content: connection status + name + encryption - Row( - modifier = Modifier.align(Alignment.Center), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - when { - isNostrPeer || isNostrReachableFavorite -> { - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(R.string.cd_nostr_reachable), - modifier = Modifier.size(14.dp), - tint = Color(0xFF9C27B0) - ) - } - isWifiAware -> { - Icon( - imageVector = Icons.Filled.Wifi, - contentDescription = "Direct Wi-Fi Aware", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - isDirect -> { - Icon( - imageVector = Icons.Outlined.Bluetooth, - contentDescription = "Direct Bluetooth", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - isConnected -> { - Icon( - imageVector = Icons.Filled.Route, - contentDescription = "Routed", - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - } - Text( - text = titleText, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.then(securityModifier) + // Encryption state, and the verification badge that qualifies it. Both are + // read-only for Nostr peers, which have no Noise session at all. + if (!isNostrPeer && !isNostrReachableFavorite) { + ConversationHeaderAction( + onClick = { viewModel.showSecurityVerificationSheet() }, + contentDescription = stringResource(R.string.verify_title) ) { - if (!isNostrPeer && !isNostrReachableFavorite) { + Box(contentAlignment = Alignment.Center) { NoiseSessionIcon( sessionState = sessionState, - modifier = Modifier.size(14.dp) - ) - } - - if (isVerified) { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.Filled.Verified, - contentDescription = stringResource(R.string.verify_title), - modifier = Modifier.size(14.dp), - tint = Color(0xFF32D74B) // iOS Green + modifier = Modifier.size(HeaderIconSize) ) } } + } - IconButton( - onClick = { viewModel.toggleFavorite(peerID) }, - modifier = Modifier.size(28.dp) + if (isVerified) { + Box( + modifier = Modifier.size(HeaderIconSize), + contentAlignment = Alignment.Center ) { Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.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) + painter = painterResource(R.drawable.ic_spec_check), + contentDescription = stringResource(R.string.verify_title), + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.primary ) } } + + val dismiss = LocalSheetDismiss.current + ConversationHeaderAction( + onClick = { dismiss?.invoke() ?: onDismiss() }, + contentDescription = stringResource(R.string.close_plain) + ) { + Icon( + painter = painterResource(R.drawable.ic_spec_close), + contentDescription = null, + modifier = Modifier.size(HeaderIconSize), + tint = colorScheme.onSurfaceVariant + ) + } } - ) + } } } } 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..16c7f63d 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,18 @@ 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 @@ -9,16 +21,16 @@ 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 import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed 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 @@ -29,20 +41,25 @@ 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 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 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 @@ -50,6 +67,13 @@ 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.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 +import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Locale @@ -61,12 +85,119 @@ 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) + +/** + * 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. + * + * 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() + } + + // 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) } + + 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 { + isWholesaleReplacement -> emptySet() + added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet() + else -> added.mapTo(HashSet(added.size)) { it.id } + } +} + @Composable fun MessagesList( messages: List, currentUserNickname: String, meshService: MeshService, modifier: Modifier = Modifier, + mentionPeerIdentities: Map? = null, + /** + * 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), + /** + * 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, @@ -74,11 +205,23 @@ fun MessagesList( onCancelTransfer: ((BitchatMessage) -> Unit)? = null, onImageClick: ((String, List, Int) -> Unit)? = null ) { - val listState = rememberLazyListState() - + val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) { + mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages) + } + + // 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) { @@ -94,7 +237,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 @@ -114,27 +257,107 @@ 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. + // 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) + } + + // 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(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 + // worth animating. + if (previous == null || previous == messages.size) return@LaunchedEffect + placementArmed = true + delay(PlacementArmWindowMs) + placementArmed = false + } + + val layoutDirection = LocalLayoutDirection.current 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 + 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. 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 ) { - 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) + + // 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, + currentUserNickname = currentUserNickname, + meshService = meshService, + mentionPeerIdentities = resolvedMentionPeerIdentities, + showSender = !isGrouped, + topSpacing = MessageGrouping.topSpacingFor( + isGrouped = isGrouped, + isFirstInList = originalIndex == 0 + ), + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + 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 = if (placementArmed) MessagePlacementSpec else null, + fadeOutSpec = MessageFadeOutSpec + ) + .then(entryModifier) + ) } } } @@ -146,16 +369,22 @@ fun MessageItem( currentUserNickname: String, meshService: MeshService, messages: List = emptyList(), + mentionPeerIdentities: Map = emptyMap(), + showSender: Boolean = true, + topSpacing: Dp = 0.dp, 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()) } - + val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) } + Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -172,8 +401,10 @@ fun MessageItem( messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -209,14 +440,18 @@ fun MessageItem( messages: List, currentUserNickname: String, meshService: MeshService, + mentionPeerIdentities: Map, 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 +461,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -243,6 +479,7 @@ fun MessageItem( meshService = meshService, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, @@ -268,9 +505,11 @@ fun MessageItem( val headerText = formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -286,7 +525,7 @@ fun MessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) @@ -335,15 +574,24 @@ fun MessageItem( .align(Alignment.TopEnd) .padding(4.dp) .size(22.dp) - .background(Color.Gray.copy(alpha = 0.6f), 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 = Color.White, modifier = Modifier.size(14.dp)) + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.cd_cancel), + tint = colorScheme.onSurface, + 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 = BitchatFontFamily, + color = palette.textTertiary + ) } } } @@ -351,31 +599,15 @@ 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, - 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 - ) + if (message.sender == "system") { + // Background narration: `// Tor started. Routing all chats…` + val annotatedText = remember(message, colorScheme.onSurface) { + formatSystemMessage( + message = message, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter + ) + } val haptic = LocalHapticFeedback.current Text( @@ -388,11 +620,11 @@ fun MessageItem( } ) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface + style = ChatVisualTokens.SystemActionStyle.copy( + color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha), ) ) } else { @@ -400,8 +632,10 @@ fun MessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, colorScheme = colorScheme, timeFormatter = timeFormatter, + showSender = showSender, onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, modifier = modifier, @@ -414,37 +648,47 @@ internal fun TextMessageLayout( message: BitchatMessage, currentUserNickname: String, meshService: MeshService, + mentionPeerIdentities: Map = emptyMap(), colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, 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, + myPeerID = myPeerId, + 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, + colorScheme.onSurface, + colorScheme.secondary, + mentionPeerIdentities, + timeFormatter + ) { formatTextMessageBody( message = displayMessage, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, + timeFormatter = timeFormatter, ) } val isSelf = message.isFromSelf(currentUserNickname, myPeerId) @@ -457,12 +701,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 +717,13 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - modifier = Modifier.weight(1f), - fontFamily = FontFamily.Monospace, + modifier = Modifier + .fillMaxWidth() + .padding(top = MessageGrouping.SENDER_TOP_PADDING), + fontFamily = BitchatFontFamily, softWrap = false, overflow = TextOverflow.Ellipsis, - ) - AnnotatedClickableText( - text = metadataText, - annotationTags = emptyList(), - onAnnotationClick = { _, _ -> false }, - onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, - softWrap = false, + style = MessageSenderTextStyle, ) } @@ -512,10 +748,10 @@ internal fun TextMessageLayout( } }, onLongPress = handleLongPress, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, softWrap = true, overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface), + style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), ) } } @@ -523,53 +759,40 @@ internal fun TextMessageLayout( @Composable fun DeliveryStatusIcon(status: DeliveryStatus) { val colorScheme = MaterialTheme.colorScheme - - 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 = Color(0xFF007AFF), // Blue - fontWeight = FontWeight.Bold - ) - } - is DeliveryStatus.Failed -> { - Text( - text = stringResource(R.string.status_failed), - fontSize = 10.sp, - color = Color.Red.copy(alpha = 0.8f) - ) - } - 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, colorScheme.secondary, FontWeight.Bold) + DeliveryStatus.Failed::class -> + Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal) + // A single subdued check, without the numeric label. + else -> + Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal) } + + Text( + text = stringResource(text), + fontSize = 10.sp, + color = color, + fontWeight = weight + ) } } 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..9b3278c4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt @@ -0,0 +1,84 @@ +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. 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 = ChatVisualTokens.MessageItemSpacing + + /** Space above a continuation message inside an existing group. */ + val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing + + /** Gap between the sender label and the first line of the body. */ + 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: + * 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/PeerIdentity.kt b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt new file mode 100644 index 00000000..47dd5629 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Canonical, presentation-neutral identity used to derive a peer hue. + * + * Callers cannot construct arbitrary color seeds. Every identity is normalized and namespaced + * here so the same mesh or Nostr user resolves to the same color on every surface. + */ +@JvmInline +value class PeerIdentity private constructor(internal val stableKey: String) { + companion object { + fun mesh(peerID: String): PeerIdentity = + PeerIdentity("noise:${normalize(peerID)}") + + /** + * Preserve the established geohash-chat color mapping while accepting a full public key. + * + * Older chat rendering hashed `nostr:nostr:`. Keeping that visual + * key here avoids recoloring existing conversations; the important change is that every + * surface now derives it from the same canonical Nostr identity. + */ + fun nostr(pubkeyHex: String): PeerIdentity { + val normalized = normalizeNostrIdentifier(pubkeyHex) + return PeerIdentity("nostr:nostr:${normalized.take(8)}") + } + + /** + * Last-resort identity for legacy messages and mentions that carry no stable peer ID. + */ + fun nickname(nickname: String): PeerIdentity = + PeerIdentity(normalize(nickname)) + + private fun normalize(value: String): String = + value.trim().lowercase(Locale.ROOT) + + private fun normalizeNostrIdentifier(value: String): String { + var normalized = normalize(value) + while (normalized.startsWith("nostr:") || normalized.startsWith("nostr_")) { + normalized = normalized.removePrefix("nostr:").removePrefix("nostr_") + } + return normalized + } + } +} + +/** + * Resolve the canonical identity attached to a rendered message. + * + * Full Nostr keys take precedence over routing aliases such as `nostr_abcd…`; aliases remain a + * compatibility fallback for messages saved by older app versions. + */ +fun peerIdentityForMessage(message: BitchatMessage): PeerIdentity { + message.senderNostrPubkey?.takeIf { it.isNotBlank() }?.let { + return PeerIdentity.nostr(it) + } + + val senderPeerID = message.senderPeerID + return when { + senderPeerID?.startsWith("nostr:", ignoreCase = true) == true || + senderPeerID?.startsWith("nostr_", ignoreCase = true) == true -> { + PeerIdentity.nostr(senderPeerID) + } + senderPeerID?.length == 16 || senderPeerID?.length == 64 -> { + PeerIdentity.mesh(senderPeerID) + } + else -> PeerIdentity.nickname(message.sender) + } +} 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/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 2633f888..40da30be 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -1,9 +1,15 @@ 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 -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,13 +18,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 @@ -39,14 +38,15 @@ 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 import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet private data class SecurityStatusInfo( @@ -69,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( @@ -92,7 +92,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 { @@ -155,13 +155,14 @@ 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 ) Spacer(modifier = Modifier.weight(1f)) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } @@ -219,7 +220,7 @@ private fun SecurityStatusCard( Text( text = displayName, style = MaterialTheme.typography.titleMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = accent @@ -227,7 +228,7 @@ private fun SecurityStatusCard( Text( text = statusInfo.text, style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace + fontFamily = BitchatFontFamily ), color = accent.copy(alpha = 0.8f) ) @@ -257,7 +258,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_start_handshake), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -273,7 +274,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(), @@ -289,7 +290,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -303,7 +304,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(), @@ -320,7 +321,7 @@ private fun SecurityVerificationActions( ) { Text( text = stringResource(R.string.fingerprint_mark_verified), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -349,7 +350,7 @@ private fun VerificationStatusRow( Text( text = text, style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontWeight = FontWeight.Bold ), color = textTint @@ -375,7 +376,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) @@ -385,7 +386,7 @@ private fun FingerprintBlock( Text( text = formatFingerprint(fingerprint), style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ), color = accent, @@ -417,7 +418,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 97392eae..a84c3c94 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 @@ -17,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 @@ -31,8 +32,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 @@ -60,7 +59,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,8 +68,10 @@ 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 import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.services.VerificationService import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -98,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() @@ -144,7 +143,7 @@ fun VerificationSheet( text = { Text( text = "My QR", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -155,7 +154,7 @@ fun VerificationSheet( text = { Text( text = "Scan", - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 14.sp ) } @@ -208,7 +207,7 @@ fun VerificationSheet( ) { Text( text = stringResource(R.string.verify_remove), - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, fontSize = 12.sp ) } @@ -232,10 +231,11 @@ private fun VerificationHeader( Text( text = stringResource(R.string.verify_title).uppercase(), fontSize = 14.sp, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } @@ -258,7 +258,7 @@ private fun MyQrTabContent( Text( text = stringResource(R.string.verify_my_qr_title), style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = accent ) @@ -283,7 +283,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) ) @@ -296,7 +296,7 @@ private fun MyQrTabContent( Text( text = nickname, style = MaterialTheme.typography.headlineSmall, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ) @@ -307,7 +307,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 ) @@ -355,7 +355,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) @@ -386,7 +386,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 ) @@ -397,7 +397,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/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt index c61f2f9a..c9922bf1 100644 --- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt @@ -1,24 +1,18 @@ package com.bitchat.android.ui -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 android.Manifest +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.size 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 @@ -27,21 +21,59 @@ 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 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 + 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 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) } @@ -53,71 +85,127 @@ fun VoiceRecordButton( val latestOnStart = rememberUpdatedState(onStart) val latestOnAmplitude = rememberUpdatedState(onAmplitude) val latestOnFinish = rememberUpdatedState(onFinish) + val latestOnCancel = rememberUpdatedState(onCancel) - Box( + // 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( + isActive = isRecording || isCapturing, + isPressed = isCapturing, modifier = modifier - .size(32.dp) - .background(backgroundColor, CircleShape) .pointerInput(Unit) { detectTapGestures( onPress = { - if (!isRecording) { - if (micPermission.status !is PermissionStatus.Granted) { - micPermission.launchPermissionRequest() - return@detectTapGestures - } - val rec = VoiceRecorder(context) - val f = rec.start() - recorder = rec - isRecording = f != null - recordedFilePath = f?.absolutePath - recordingStart = System.currentTimeMillis() - if (isRecording) { - 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) { - 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) { - val file = recorder?.stop() - isRecording = 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 (isRecording) { - // Extend recording for 500ms after release to avoid clipping - delay(500) + if (isCapturing) { + // Keep going briefly past the release so the tail is not clipped. + delay(ReleaseTailMs) } - if (isRecording) { + if (isCapturing) { val file = recorder?.stop() - isRecording = false + 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() @@ -125,14 +213,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(20.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } } 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 32427c44..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 @@ -3,6 +3,14 @@ package com.bitchat.android.ui.debug import android.content.ClipData import android.content.ClipboardManager import android.widget.Toast +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.* @@ -12,25 +20,17 @@ 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 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, @@ -94,10 +94,10 @@ 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, + 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", 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/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 7c0a1ac6..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 @@ -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 @@ -13,14 +13,15 @@ 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 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) { @@ -50,9 +53,11 @@ fun AudioMessageItem( val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString( message = message, currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -68,7 +73,7 @@ fun AudioMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) 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..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 @@ -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 @@ -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 2964f9c0..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 @@ -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 @@ -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 35b0bbea..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 @@ -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.* @@ -21,12 +21,13 @@ 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 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,20 @@ 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 + myPeerID = meshService.myPeerID, + palette = palette, + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + includeSender = showSender ) val haptic = LocalHapticFeedback.current AnnotatedClickableText( @@ -66,7 +71,7 @@ fun ImageMessageItem( } }, onLongPress = { onMessageLongPress?.invoke(message) }, - fontFamily = FontFamily.Monospace, + fontFamily = BitchatFontFamily, color = colorScheme.onSurface, ) @@ -138,7 +143,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/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt index eb1f5d86..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 @@ -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 @@ -7,23 +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.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 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.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import com.bitchat.android.features.media.ImageUtils +import com.bitchat.android.ui.ComposerActionSurface +import com.bitchat.android.ui.ComposerIconSize import java.io.File @OptIn(ExperimentalFoundationApi::class) @@ -86,26 +87,31 @@ fun ImagePickerButton( } } - Box( - modifier = modifier - .size(32.dp) - .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 = Color.Gray, - modifier = Modifier.size(20.dp) + tint = tint, + modifier = Modifier.size(ComposerIconSize) ) } 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..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,13 +1,14 @@ 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 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 @@ -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 new file mode 100644 index 00000000..96ef57d4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -0,0 +1,91 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Bitchat-specific color tokens that do not have a faithful Material 3 semantic role. + * + * 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( + // MARK: - Form controls + /** + * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted + * 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. */ + 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: - Extra semantics + /** Timestamps, placeholders, section labels, disabled states. */ + val textTertiary: Color, + /** Self, mentions targeting you, unread DMs. */ + val accentOrange: 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( + inputOutline = Color(0xFF333635), + inputOutlineFocused = Color(0xFF5A605D), + inputSurface = Color(0xFF0B0B0B), + inputSurfaceFocused = Color(0xFF151515), + inputButton = Color(0xFF1E1E1E), + textTertiary = Color(0xFF6B776B), + accentOrange = Color(0xFFFF9F0A), + accentPurple = Color(0xFFBF5AF2), + peerColorSaturation = 1f, + peerColorValue = 1f, +) + +val LightBitchatPalette = BitchatPalette( + inputOutline = Color(0xFFCFD3D1), + inputOutlineFocused = Color(0xFF8E9490), + inputSurface = Color(0xFFFAFAFA), + inputSurfaceFocused = Color(0xFFF2F2F2), + inputButton = Color(0xFFE8E8E8), + textTertiary = Color(0xFF757F75), + accentOrange = Color(0xFFFF9500), + accentPurple = Color(0xFFAF52DE), + peerColorSaturation = 0.85f, + peerColorValue = 0.45f, +) + +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/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/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt new file mode 100644 index 00000000..b0f1ed7d --- /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.PeerIdentity +import kotlin.math.abs + +/** + * The single identity-to-color boundary used by chat, people sheets, and mentions. + * + * 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 colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color { + var hash = 5381UL + for (byte in identity.stableKey.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 76ef3130..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 @@ -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,30 +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 -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 = Color.Black, - onBackground = Color(0xFF39FF14), // Green on black - surface = Color(0xFF111111), // Very dark gray - onSurface = Color(0xFF39FF14), // Green text - error = Color(0xFFFF5555), // Red for errors + 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 = Color.White, - onBackground = Color(0xFF008000), // Dark green on white - surface = Color(0xFFF8F8F8), // Very light gray - onSurface = Color(0xFF008000), // Dark green text - error = Color(0xFFCC0000), // Dark red for errors + 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 ) @@ -60,7 +82,8 @@ 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 SideEffect { @@ -83,9 +106,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..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,53 +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 = ChatVisualTokens.MessageBodyStyle + +/** Sender label above a message group. Single line, never wraps. */ +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 + 3).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 = BitchatFontFamily, + fontWeight = FontWeight.Bold, + fontSize = (BASE_FONT_SIZE + 13).sp, + lineHeight = (BASE_FONT_SIZE + 21).sp + ), + titleLarge = TextStyle( + 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 = 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/drawable/ic_spec_bluetooth.xml b/app/src/main/res/drawable/ic_spec_bluetooth.xml new file mode 100644 index 00000000..8c1ed7ae --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_bluetooth.xml @@ -0,0 +1,9 @@ + + + + + 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_check.xml b/app/src/main/res/drawable/ic_spec_check.xml new file mode 100644 index 00000000..16eb9e1c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_check.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/ic_spec_close.xml b/app/src/main/res/drawable/ic_spec_close.xml new file mode 100644 index 00000000..e0fbc4ea --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_close.xml @@ -0,0 +1,8 @@ + + + + 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_envelope.xml b/app/src/main/res/drawable/ic_spec_envelope.xml new file mode 100644 index 00000000..15552d37 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_envelope.xml @@ -0,0 +1,8 @@ + + + + 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_lock_open.xml b/app/src/main/res/drawable/ic_spec_lock_open.xml new file mode 100644 index 00000000..11d69821 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_lock_open.xml @@ -0,0 +1,13 @@ + + + + + + + + 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_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/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_routed.xml b/app/src/main/res/drawable/ic_spec_routed.xml new file mode 100644 index 00000000..36e271d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_routed.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_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 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_spec_sync.xml b/app/src/main/res/drawable/ic_spec_sync.xml new file mode 100644 index 00000000..1ab434e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_sync.xml @@ -0,0 +1,10 @@ + + + + + + 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_warning.xml b/app/src/main/res/drawable/ic_spec_warning.xml new file mode 100644 index 00000000..ffb64e3c --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_warning.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.xml b/app/src/main/res/drawable/ic_spec_wifi.xml new file mode 100644 index 00000000..4757653f --- /dev/null +++ b/app/src/main/res/drawable/ic_spec_wifi.xml @@ -0,0 +1,9 @@ + + + + + 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 @@ + + + + + + + + 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 00000000..90eb8a86 Binary files /dev/null and b/app/src/main/res/font/geist_mono_bold.ttf differ 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 00000000..ff49ece5 Binary files /dev/null and b/app/src/main/res/font/geist_mono_medium.ttf differ 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 00000000..50c9d5a6 Binary files /dev/null and b/app/src/main/res/font/geist_mono_regular.ttf differ diff --git a/app/src/main/res/font/geist_mono_semibold.ttf b/app/src/main/res/font/geist_mono_semibold.ttf new file mode 100644 index 00000000..1b21724d Binary files /dev/null and b/app/src/main/res/font/geist_mono_semibold.ttf differ 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/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c81bdb26..13c8d51c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -71,12 +71,8 @@ إضافة إشارة مرجعية - رجوع - القناة: %1$s - مغادرة يمكن الوصول عبر Nostr رسائل خاصة غير مقروءة - تبديل الإشارة المرجعية تم الانتقال حالة Tor الأقران المتصلون @@ -96,14 +92,12 @@ اختيار صورة - دردشة شبكية لامركزية مع تشفير طرف إلى طرف دردشة شبكية دون اتصال تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق. قنوات geohash عبر الإنترنت تواصل مع أشخاص بالقرب منك عبر قنوات تعتمد على geohash. وسّع الشبكة عبر مرحلات الإنترنت العامة. تشفير طرف إلى طرف الرسائل الخاصة مشفّرة. رسائل القنوات عامة. - المظهر النظام فاتح داكن @@ -127,7 +121,6 @@ حالة Tor: %1$s، التمهيد %2$d%% آخر: %1$s مسح البيانات الطارئ - تلميح: انقر ثلاث مرات على عنوان التطبيق لمسح كل البيانات المحفوظة – بما في ذلك الرسائل والمفاتيح والإعدادات. إعدادات التصحيح مفتوح المصدر • الخصوصية أولاً • لامركزي إغلاق @@ -147,7 +140,6 @@ فتح الخريطة إزالة الإشارة المرجعية انتقال - محدد مغادرة القناة يمكن الوصول عبر Nostr مفضل (غير متصل) @@ -286,14 +278,12 @@ الأذونات منح الأذونات bitchat لا يتتبع موقعك - الأشخاص لا أحد هنا… (أنت) حرّك وقرّب لاختيار geohash اختيار اكتب رسالة… إشارة - (~%1$s) v%1$s image/* صورة @@ -307,11 +297,6 @@ تشغيل إيقاف مؤقت - تعدين PoW - PoW مفعّل - برهان العمل - يتم التعدين… - pow: %1$dbit مطلوب لاكتشاف مستخدمي bitchat عبر البلوتوث diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 95b3662a..7c2617a4 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -71,12 +71,8 @@ বুকমার্ক যোগ করুন - ফিরে যান - চ্যানেল: %1$s - ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় অপঠিত ব্যক্তিগত বার্তা - বুকমার্ক টগল করুন টেলিপোর্ট করা হয়েছে Tor অবস্থা সংযুক্ত পিয়ার @@ -96,14 +92,12 @@ ছবি নির্বাচন করুন - এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট অফলাইন মেশ চ্যাট ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়। অনলাইন জিওহ্যাশ চ্যানেল জিওহ্যাশ-ভিত্তিক চ্যানেলের মাধ্যমে আপনার এলাকার মানুষদের সাথে সংযুক্ত হন। পাবলিক ইন্টারনেট রিলেগুলির মাধ্যমে মেশ বিস্তৃত করুন। এন্ড-টু-এন্ড এনক্রিপশন ব্যক্তিগত বার্তাগুলি এনক্রিপ্ট করা। চ্যানেল বার্তাগুলি পাবলিক। - চেহারা সিস্টেম হালকা অন্ধকার @@ -127,7 +121,6 @@ Tor অবস্থা: %1$s, বুটস্ট্র্যাপ %2$d%% শেষ: %1$s জরুরী ডেটা ক্লিয়ার - টিপ: সংরক্ষিত সমস্ত ডেটা – বার্তা, কী এবং সেটিংস সহ – সাফ করতে অ্যাপ শিরোনামে তিনবার ট্যাপ করুন। ডিবাগ সেটিংস ওপেন সোর্স • গোপনীয়তা প্রথম • বিকেন্দ্রীভূত বন্ধ করুন @@ -147,7 +140,6 @@ মানচিত্র খুলুন বুকমার্ক সরান টেলিপোর্ট - নির্বাচিত চ্যানেল ছেড়ে দিন Nostr এর মাধ্যমে পৌঁছানো যায় প্রিয় (অফলাইন) @@ -286,14 +278,12 @@ অনুমতি অনুমতি দিন bitchat আপনার অবস্থান ট্র্যাক করে না - মানুষ কেউ আশেপাশে নেই … (আপনি) জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন নির্বাচন করুন বার্তা টাইপ করুন … উল্লেখ - (~%1$s) v%1$s image/* ছবি @@ -307,11 +297,6 @@ প্লে করুন বিরাম - মাইনিং PoW - PoW সক্রিয় - প্রুফ অফ ওয়ার্ক - মাইনিং … - pow: %1$dbit ব্লুটুথের মাধ্যমে bitchat ব্যবহারকারী আবিষ্কার করতে প্রয়োজন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a80dc4c4..b13fb001 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -71,12 +71,8 @@ Lesezeichen hinzufügen - zurück - Kanal: %1$s - verlassen Über Nostr erreichbar Ungelesene private Nachrichten - Lesezeichen umschalten Teleportiert Tor‑Status Verbundenen Peers @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Karte öffnen Lesezeichen entfernen Teleportieren - Ausgewählt Kanal verlassen Über Nostr erreichbar Favorit (offline) @@ -286,14 +278,12 @@ Berechtigungen Berechtigungen erteilen bitchat verfolgt NICHT deinen Standort - PERSONEN niemand da … (du) schwenken und zoomen, um einen Geohash auszuwählen auswählen Nachricht eingeben … erwähnen - (~%1$s) v%1$s image/* Bild @@ -307,11 +297,6 @@ Abspielen Pause - Mining‑PoW - PoW aktiviert - Proof of Work - Mining … - pow: %1$dbit Erforderlich, um bitchat‑Benutzer über Bluetooth zu entdecken diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 157ea1b4..e1ac2c2b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -71,12 +71,8 @@ Agregar marcador - atrás - Canal: %1$s - salir Accesible vía Nostr Mensajes privados sin leer - Alternar marcador Teletransportado Estado de Tor Pares conectados @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Abrir mapa Quitar marcador Teletransportar - Seleccionado Salir del canal Accesible vía Nostr Favorito (sin conexión) @@ -286,14 +278,12 @@ Permisos Otorgar permisos bitchat NO rastrea tu ubicación - PERSONAS nadie por aquí… (tú) desplaza y hace zoom para seleccionar un geohash seleccionar Escribe un mensaje… mencionar - (~%1$s) v%1$s image/* Imagen @@ -307,11 +297,6 @@ Reproducir Pausar - Minando PoW - PoW habilitado - Prueba de trabajo - Minando… - pow: %1$dbit Requerido para descubrir usuarios bitchat a través de Bluetooth diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8d7d6ae9..a3e81d29 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -71,12 +71,8 @@ افزودن نشانک - بازگشت - کانال: %1$s - خروج قابل دسترس از طریق Nostr پیام‌های خصوصی خوانده‌نشده - تغییر وضعیت نشانک انتقال مکانی وضعیت Tor همتایان متصل @@ -96,14 +92,12 @@ انتخاب تصویر - گفت‌وگوی مِش غیرمتمرکز با رمزگذاری سرتاسری گفت‌وگوی مِش آفلاین بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیام‌ها از طریق دستگاه‌های نزدیک رله می‌شوند تا بُرد افزایش یابد. کانال‌های geohash آنلاین از طریق کانال‌های مبتنی بر geohash با افراد منطقهٔ خود ارتباط برقرار کنید. مِش را از طریق رله‌های عمومی اینترنت گسترش دهید. رمزگذاری سرتاسری پیام‌های خصوصی رمزگذاری می‌شوند. پیام‌های کانال عمومی هستند. - نما سامانه روشن تیره @@ -127,7 +121,6 @@ وضعیت Tor: %1$s، بوت‌استرپ %2$d%% آخرین: %1$s پاکسازی اضطراری داده - نکته: برای پاک کردن همهٔ داده‌های ذخیره‌شده — شامل پیام‌ها، کلیدها و تنظیمات — سه بار روی عنوان برنامه ضربه بزنید. تنظیمات اشکال‌زدایی متن‌باز • حریم خصوصی در اولویت • غیرمتمرکز بستن @@ -147,7 +140,6 @@ باز کردن نقشه حذف نشانک انتقال - انتخاب شد خروج از کانال قابل دسترس از طریق Nostr دلخواه (آفلاین) @@ -286,14 +278,12 @@ مجوزها اعطای مجوزها bitchat موقعیت شما را پیگیری نمی‌کند - افراد کسی در اطراف نیست … (شما) برای انتخاب geohash، جابه‌جا و زوم کنید انتخاب نوشتن پیام … ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ پخش توقف - استخراج PoW - PoW فعال است - گواه کار - در حال استخراج … - pow: %1$dbit برای یافتن کاربران bitchat از طریق بلوتوث لازم است diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 829079a6..1da03f06 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -71,12 +71,8 @@ Mag‑bookmark - bumalik - channel: %1$s - umalis Maabot sa Nostr Hindi nabasang pribadong mensahe - I‑toggle ang bookmark Naiteleport Katayuan ng Tor Nakakonektang peers @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Buksan ang mapa Alisin ang bookmark Teleport - Napili Umalis sa channel Maabot sa Nostr Paborito (offline) @@ -281,7 +273,6 @@ HINDI sinusubaybayan ng bitchat ang lokasyon mo @ · ⧉ - TAO walang tao sa paligid… (ikaw) i‑pan at i‑zoom para pumili ng geohash @@ -290,7 +281,6 @@ @%1$s banggit %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 46e9c023..e632bdb8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -71,12 +71,8 @@ Ajouter un signet - retour - canal : %1$s - quitter Joignable via Nostr Messages privés non lus - Basculer le signet Téléporté Statut Tor Pairs connectés @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Ouvrir la carte Retirer le signet Téléporter - Sélectionné Quitter le canal Joignable via Nostr Favori (hors‑ligne) @@ -282,7 +274,6 @@ bitchat ne suit PAS ta position @ · ⧉ - PERSONNES personne aux alentours… (toi) déplacer et zoomer pour choisir un geohash @@ -291,7 +282,6 @@ @%1$s mention %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index ffb4dac0..1a393f38 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -71,12 +71,8 @@ बुकमार्क जोड़ें - वापस - चैनल: %1$s - छोड़ें Nostr के माध्यम से उपलब्ध अपठित निजी संदेश - बुकमार्क टॉगल करें टेलीपोर्ट हुआ Tor स्थिति कनेक्टेड पीयर्स @@ -96,14 +92,12 @@ छवि चुनें - विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन ऑफ़लाइन मेष चैट इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े। ऑनलाइन geohash चैनल geohash आधारित चैनलों के माध्यम से अपने आस‑पास के लोगों से जुड़ें। सार्वजनिक इंटरनेट रिले के माध्यम से मेष का विस्तार करें। एंड‑टू‑एंड एन्क्रिप्शन निजी संदेश एन्क्रिप्टेड हैं। चैनल संदेश सार्वजनिक हैं। - रूप सिस्टम लाइट डार्क @@ -127,7 +121,6 @@ Tor स्थिति: %1$s, बूटस्ट्रैप %2$d%% अंतिम: %1$s आपातकालीन डेटा मिटाना - संकेत: सभी सहेजे डेटा – संदेश, कुंजियाँ और सेटिंग्स सहित – मिटाने के लिए ऐप शीर्षक पर तीन बार टैप करें। डिबग सेटिंग्स ओपन सोर्स • गोपनीयता पहले • विकेन्द्रीकृत बंद करें @@ -147,7 +140,6 @@ मानचित्र खोलें बुकमार्क हटाएँ टेलीपोर्ट - चयनित चैनल छोड़ें Nostr के माध्यम से उपलब्ध पसंदीदा (ऑफ़लाइन) @@ -286,14 +278,12 @@ अनुमतियाँ अनुमतियाँ दें bitchat आपकी लोकेशन ट्रैक नहीं करती - लोग आस‑पास कोई नहीं… (आप) geohash चुनने के लिए पैन/ज़ूम करें चयन करें संदेश लिखें… उल्लेख - (~%1$s) v%1$s image/* छवि @@ -307,11 +297,6 @@ चलाएँ रोकें - PoW माइनिंग - PoW सक्षम - प्रूफ़ ऑफ़ वर्क - माइनिंग… - pow: %1$dbit ब्लूटूथ के माध्यम से bitchat उपयोगकर्ताओं की खोज के लिए आवश्यक diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index e67caded..934556e4 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -71,12 +71,8 @@ Tambah bookmark - kembali - Channel: %1$s - keluar Dapat dijangkau melalui Nostr Pesan pribadi yang belum dibaca - Alihkan bookmark Diteleportasi Status Tor Peer yang terhubung @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Buka peta Hapus bookmark Teleportasi - Dipilih Keluar dari channel Dapat dijangkau melalui Nostr Favorit (offline) @@ -286,14 +278,12 @@ Izin Berikan izin bitchat TIDAK melacak lokasi Anda - ORANG tidak ada orang di sekitar … (Anda) geser dan zoom untuk memilih geohash pilih Ketik pesan … sebut - (~%1$s) v%1$s image/* Gambar @@ -307,11 +297,6 @@ Putar Jeda - Mining PoW - PoW diaktifkan - Proof of Work - Mining … - pow: %1$dbit Diperlukan untuk menemukan pengguna bitchat melalui Bluetooth diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 92bf4ad0..7e67de6d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -71,12 +71,8 @@ Aggiungi segnalibro - indietro - canale: %1$s - esci Raggiungibile via Nostr Messaggi privati non letti - Attiva/disattiva segnalibro Teletrasportato Stato Tor Peer connessi @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Apri mappa Rimuovi segnalibro Teletrasporta - Selezionato Esci dal canale Raggiungibile via Nostr Preferito offline @@ -286,14 +278,12 @@ permessi Concedi permessi bitchat NON traccia la tua posizione - PERSONE nessuno nei dintorni… (tu) scorri e ingrandisci per selezionare un geohash seleziona scrivi un messaggio… menzione - (~%1$s) v%1$s image/* Immagine @@ -307,11 +297,6 @@ Riproduci Pausa - Mining PoW - PoW abilitato - Proof of Work - mining… - pow: %1$dbit Necessario per scoprire utenti bitchat tramite Bluetooth diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1b25aa95..1e7da71d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -71,12 +71,8 @@ ブックマークに追加 - 戻る - チャンネル: %1$s - 退出 Nostr 経由で到達可能 未読のプライベートメッセージ - ブックマークを切り替え テレポート済み Tor ステータス 接続中のピア @@ -96,14 +92,12 @@ 画像を選択 - 分散型メッシュチャット・エンドツーエンド暗号化 オフライン・メッシュチャット インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。 オンライン Geohash チャンネル Geohash ベースのチャンネルで近くの人々とつながります。公開インターネットのリレーを介してメッシュを拡張します。 エンドツーエンド暗号化 プライベートメッセージは暗号化されます。チャンネルメッセージは公開です。 - 表示 システム ライト ダーク @@ -127,7 +121,6 @@ Tor ステータス: %1$s、ブートストラップ %2$d%% 最終: %1$s 緊急データ消去 - ヒント: アプリタイトルを 3 回タップすると、メッセージ・鍵・設定を含む保存データをすべて消去します。 デバッグ設定 オープンソース • プライバシーファースト • 分散型 閉じる @@ -147,7 +140,6 @@ 地図を開く ブックマークを削除 テレポート - 選択済み チャンネルから退出 Nostr 経由で到達可能 お気に入り (オフライン) @@ -286,14 +278,12 @@ 権限 権限を付与 bitchat はあなたの位置情報を追跡しません - 人々 誰もいません… (あなた) パン/ズームして geohash を選択 選択 メッセージを入力… メンション - (~%1$s) v%1$s image/* 画像 @@ -307,11 +297,6 @@ 再生 一時停止 - PoW をマイニング中 - PoW 有効 - Proof of Work - マイニング中… - pow: %1$dbit Bluetooth で bitchat ユーザーを検出するために必要です diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index c7620e19..4994f9cc 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -71,12 +71,8 @@ სანიშნეს დამატება - უკან - არხი: %1$s - გასვლა მისაწვდომია Nostr-ით წაუკითხავი პირადი შეტყობინებები - სანიშნეს გადართვა ტელეპორტირებული Tor-ის სტატუსი დაკავშირებული პირები @@ -96,14 +92,12 @@ სურათის არჩევა - დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით ოფლაინ mesh-ჩათი ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა. ონლაინ geohash არხები დაუკავშირდით ახლომდებარე ხალხს geohash-ზე დაფუძნებული არხებით. გააფართოვეთ mesh საჯარო ინტერნეტ რილეებით. ბოლო-მდე დაშიფვრა პირადი შეტყობინებები დაშიფრულია. არხის შეტყობინებები საჯაროა. - გარეგნობა სისტემა ღია მუქი @@ -127,7 +121,6 @@ Tor სტატუსი: %1$s, ჩატვირთვა %2$d%% ბოლო: %1$s სასწრაფო მონაცემების წაშლა - რჩევა: დააჭირეთ აპის სათაურს სამჯერ ყველა შენახული მონაცემის – შეტყობინებების, გასაღებებისა და პარამეტრების – წასაშლელად. დebug პარამეტრები ღია კოდი • კონფიდენციალობა პირველია • დეცენტრალიზებული დახურვა @@ -147,7 +140,6 @@ რუკის გახსნა სანიშნეს წაშლა ტელეპორტი - მონიშნული არხიდან გამოსვლა მისაწვდომია Nostr-ით რჩეული (ოფლაინ) @@ -286,14 +278,12 @@ ნებართვები ნებართვების მინიჭება bitchat არ აკონტროლებს თქვენს მდებარეობას - ხალხი არავინ არის ახლოს… (თქვენ) პანორამირება/ზუმი geohash-ის ასარჩევად არჩევა შეიყვანეთ შეტყობინება… ხსენება - (~%1$s) v%1$s image/* სურათი @@ -307,11 +297,6 @@ დაკვრა პაუზა - PoW მაინინგი - PoW ჩართულია - Proof of Work - მაინინგი… - pow: %1$dbit საჭიროა bitchat მომხმარებლების Bluetooth-ით აღმოსაჩენად diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8c0cc624..7327257a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -71,12 +71,8 @@ 북마크 추가 - 뒤로 - 채널: %1$s - 나가기 Nostr로 연결 가능 읽지 않은 개인 메시지 - 북마크 전환 텔레포트됨 Tor 상태 연결된 피어 @@ -96,14 +92,12 @@ 이미지 선택 - 종단간 암호화를 갖춘 탈중앙 메쉬 채팅 오프라인 메쉬 채팅 인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다. 온라인 geohash 채널 geohash 기반 채널을 통해 주변 사람들과 연결하세요. 공용 인터넷 릴레이로 메쉬를 확장합니다. 종단간 암호화 개인 메시지는 암호화됩니다. 채널 메시지는 공개입니다. - 모양 시스템 라이트 다크 @@ -127,7 +121,6 @@ Tor 상태: %1$s, 부트스트랩 %2$d%% 마지막: %1$s 긴급 데이터 삭제 - 팁: 저장된 모든 데이터(메시지, 키, 설정 포함)를 삭제하려면 앱 제목을 세 번 탭하세요. 디버그 설정 오픈 소스 • 프라이버시 우선 • 탈중앙 닫기 @@ -147,7 +140,6 @@ 지도 열기 북마크 제거 텔레포트 - 선택됨 채널 나가기 Nostr로 연결 가능 즐겨찾기 (오프라인) @@ -286,14 +278,12 @@ 권한 권한 허용 bitchat은 사용자의 위치를 추적하지 않습니다 - 사람들 주변에 아무도 없음 … (나) geohash를 선택하려면 이동하고 확대/축소하세요 선택 메시지 입력 … 멘션 - (~%1$s) v%1$s image/* 이미지 @@ -307,11 +297,6 @@ 재생 일시정지 - PoW 채굴 - PoW 사용 - 작업증명 - 채굴 중 … - pow: %1$dbit 블루투스를 통해 bitchat 사용자를 발견하는 데 필요 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index ddff38ad..168eebe7 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -71,12 +71,8 @@ Hanampy bookmark - miverina - fantsona: %1$s - hiala Azo tratrarina amin\'ny alalan\'ny Nostr Hafatra manokana tsy voavaky - Hanova bookmark Nafindra toerana Toe-javatra Tor Mpiara-miasa mifandray @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Hanokatra sarintany Hanesorana bookmark Hamindra toerana - Voafidy Hiala amin\'ny fantsona Azo tratrarina amin\'ny alalan\'ny Nostr Ankafizina tsy an-tserasera @@ -290,7 +282,6 @@ Ny bitchat dia TSY manaraka ny toerananao @ · ⧉ - OLONA tsy misy olona manodidina... (ianao) ahetsika sy ataovy lehibe mba hifidianana geohash @@ -299,7 +290,6 @@ @%1$s hanonona %1$d / %2$d - (~%1$s) @%1$s v%1$s # @@ -333,11 +323,6 @@ Hilalao Hijanona - Mihaingam-poana PoW - Alefa ny PoW - Porofo Asa - mihaingana... - pow: %1$dbit Ilaina mba hahitana mpampiasa bitchat amin\'ny alalan\'ny Bluetooth diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 3e50146a..48a7afbd 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -71,12 +71,8 @@ बुकमार्क थप्नुहोस् - पछाडि - च्यानल: %1$s - छोड्नुहोस् Nostr मार्फत पुग्न सकिने नपढिएका निजी सन्देश - बुकमार्क टगल टेलिपोर्ट भयो Tor स्थिति जडान भएका साथीहरू @@ -96,14 +92,12 @@ तस्वीर छान्नुहोस् - विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित अफलाइन मेष च्याट इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ। अनलाइन Geohash च्यानलहरू Geohash आधारित च्यानलमार्फत नजिकका मानिसहरूसँग जोडिनुहोस्। सार्वजनिक इन्टरनेट रिलेलाई प्रयोग गरेर मेष विस्तार गर्नुहोस्। एन्ड‑टु‑एन्ड इन्क्रिप्सन निजी सन्देश इन्क्रिप्टेड हुन्छन्। च्यानल सन्देश सार्वजनिक हुन्छन्। - देखावट सिस्टम हल्का गाढा @@ -127,7 +121,6 @@ Tor स्थिति: %1$s, बुटस्ट्र्याप %2$d%% अन्तिम: %1$s आपतकालीन डेटा मेटाउने - सुझाव: एप शीर्षक तीन पटक ट्याप गरी सबै डेटा (सन्देश, कुञ्जी, सेटिङ) मेटाउनुहोस्। डिबग सेटिङ Open Source • Privacy First • विकेन्द्रित बन्द गर्नुहोस् @@ -147,7 +140,6 @@ नक्सा खोल्नुहोस् बुकमार्क हटाउनुहोस् टेलिपोर्ट - छानिएको च्यानल छोड्नुहोस् Nostr बाट पहुँचयोग्य मनपर्ने (अफलाइन) @@ -281,7 +273,6 @@ bitchat ले तपाईंको स्थान पछ्याउँदैन @ · ⧉ - मानिसहरू वरिपरि कोही छैन… (तपाईं) geohash छान्न प्यान र जुम गर्नुहोस् @@ -290,7 +281,6 @@ @%1$s उल्लेख %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 38385885..0eeaec55 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -71,12 +71,8 @@ Bladwijzer toevoegen - terug - kanaal: %1$s - verlaten Bereikbaar via Nostr Ongelezen privéberichten - Bladwijzer omschakelen Geteleporteerd Tor-status Verbonden peers @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Kaart openen Bladwijzer verwijderen Teleporteren - Geselecteerd Kanaal verlaten Bereikbaar via Nostr Offline favoriet @@ -286,14 +278,12 @@ toestemmingen Toestemmingen verlenen bitchat volgt NIET uw locatie - PERSONEN niemand in de buurt… (jij) pan en zoom om een geohash te selecteren selecteren typ een bericht… vermelden - (~%1$s) v%1$s image/* Afbeelding @@ -307,11 +297,6 @@ Afspelen Pauzeren - Mining-PoW - PoW ingeschakeld - Proof of Work - minen… - pow: %1$dbit Vereist om bitchat-gebruikers via Bluetooth te ontdekken diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index e92d2d51..5f5f5f75 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -71,12 +71,8 @@ بُک مارک پاؤ - واپس - چینل: %1$s - باہر آؤ Nostr راہین پہنچ ممکن اے انہ پڑھیا ذاتی پیغام - بُک مارک ٹوگل کرو ٹیلپورٹ کیتا گیا Tor حالت جُڑے پیئر @@ -96,14 +92,12 @@ تصویر چُنو - اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ آف لائن میش چیٹ انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔ آن لائن geohash چینل geohash بیسڈ چینلاں راہین اپنے علاقے دے لوکاں نال جڑو۔ پبلک انٹرنیٹ ریلے راہین میش نوں وسیع کرو۔ اینڈ ٹو اینڈ انکرپشن ذاتی پیغام انکرپٹڈ ہوندے نیں۔ چینل پیغام پبلک ہوندے نیں۔ - دِکھ سسٹم ہلکا گھُپ @@ -127,7 +121,6 @@ Tor حالت: %1$s، بوٹ اسٹرَیپ %2$d%% آخری: %1$s ایمرجنسی ڈیٹا صاف - ٹِپ: سارے محفوظ ڈیٹا (پیغام، کُنجیاں، سیٹنگا) مٹاؤن لئی ایپ دے عنوان تے تین وار ٹیپ کرو۔ ڈی بگ سیٹنگا اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کرو @@ -147,7 +140,6 @@ نقشہ کھولو بُک مارک ہٹاؤ ٹیلپورٹ - چُنے ہوۓ چینل چھوڑو Nostr راہین پہنچ ممکن اے پسندیدہ (آف لائن) @@ -286,14 +278,12 @@ اجازتاں اجازت دو bitchat توانڈی لوکیشن نئیں ٹریک کردا - لوگ کوئی وی نیڑے نئیں … (تُسی) geohash چُنن لئی پین تے زوم کرو چُنو پیغام لکھو … ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ چلاؤ روکੋ - PoW مائننگ - PoW چالو - Proof of Work - مائننگ … - pow: %1$dbit بلوٹوتھ راہین bitchat یوزر لبھن لئی ضروری diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 67503cc6..fd4901ef 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -71,12 +71,8 @@ Adicionar favorito - voltar - Canal: %1$s - sair Acessível via Nostr Mensagens privadas não lidas - Alternar favorito Teletransportado Status do Tor Peers conectados @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Abrir mapa Remover favorito Teletransportar - Selecionado Sair do canal Acessível via Nostr Favorito (offline) @@ -286,14 +278,12 @@ 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 selecionar Digite uma mensagem… mencionar - (~%1$s) v%1$s image/* Imagem @@ -307,11 +297,6 @@ Reproduzir Pausar - Minerando PoW - PoW ativado - Prova de Trabalho - Minerando… - pow: %1$dbit Necessário para descobrir usuários do bitchat via Bluetooth diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3bcad4ab..5247916e 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -71,12 +71,8 @@ Adicionar marcador - voltar - Canal: %1$s - sair Alcançável via Nostr Mensagens privadas não lidas - Alternar marcador Teletransportado Estado do Tor Pares ligados @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,6 @@ Abrir mapa Remover marcador Teletransportar - Selecionado Sair do canal Alcançável via Nostr Favorito (offline) @@ -286,14 +278,12 @@ 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 selecionar Digite uma mensagem… mencionar - (~%1$s) v%1$s image/* Imagem @@ -307,11 +297,6 @@ Reproduzir Pausar - A minerar PoW - PoW ativado - Prova de Trabalho - A minerar… - pow: %1$dbit Necessário para descobrir utilizadores bitchat via Bluetooth diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9ce4a560..4dc08928 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -63,12 +63,8 @@ Убрать из избранного Добавить закладку - назад - канал: %1$s - выйти Доступен через Nostr Непрочитанные личные - Переключить закладку Телепортировано Статус Tor Подключённые узлы @@ -86,14 +82,12 @@ ❌ Закрыть Выбрать изображение - децентрализованный mesh‑чат со сквозным шифрованием Оффлайн Mesh‑чат Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами. Онлайн‑каналы Geohash Общайся с людьми поблизости через каналы на основе geohash. Расширяй mesh через публичные ретрансляторы. Сквозное шифрование Личные сообщения — зашифрованы. Канальные — публичны. - оформление система светлая тёмная @@ -117,7 +111,6 @@ статус Tor: %1$s, загрузка %2$d%% Последнее: %1$s Экстренное удаление данных - Совет: тройной тап по заголовку — удалить все данные (сообщения, ключи, настройки). Настройки отладки Open Source • Privacy First • Децентрализация Закрыть @@ -136,7 +129,6 @@ Открыть карту Удалить закладку Телепорт - Выбрано Покинуть канал Доступен через Nostr Избранное (оффлайн) @@ -265,7 +257,6 @@ bitchat НЕ отслеживает вашу геопозицию @ · ⧉ - ЛЮДИ никого рядом… (вы) прокрутите/масштабируйте для выбора geohash @@ -274,7 +265,6 @@ @%1$s упоминание %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 566f940e..dcfda208 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -63,12 +63,8 @@ Ta bort favorit Lägg till bokmärke - tillbaka - kanal: %1$s - lämna Nås via Nostr Olästa privata meddelanden - Växla bokmärke Teleporterad Tor‑status Anslutna noder @@ -86,14 +82,12 @@ ❌ 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 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 @@ -117,7 +111,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 @@ -136,7 +129,6 @@ Öppna karta Ta bort bokmärke Teleportera - Vald Lämna kanal Nås via Nostr Favorit (offline) @@ -265,7 +257,6 @@ bitchat spårar INTE din plats @ · ⧉ - PERSONER ingen i närheten… (du) panorera och zooma för att välja geohash @@ -274,7 +265,6 @@ @%1$s nämn %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 817cd335..7e1fc05a 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -71,12 +71,8 @@ เพิ่มที่คั่นหน้า - กลับ - ช่อง: %1$s - ออก ติดต่อได้ผ่าน Nostr ข้อความส่วนตัวยังไม่ได้อ่าน - สลับที่คั่นหน้า เทเลพอร์ตแล้ว สถานะ Tor เพียร์ที่เชื่อมต่อ @@ -96,14 +92,12 @@ เลือกภาพ - แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง แชทเมชออฟไลน์ สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ ช่อง geohash ออนไลน์ เชื่อมต่อกับผู้คนใกล้คุณผ่านช่องที่อ้างอิง geohash ขยายเมชผ่านรีเลย์อินเทอร์เน็ตสาธารณะ การเข้ารหัสแบบปลายทางถึงปลายทาง ข้อความส่วนตัวถูกเข้ารหัส ข้อความในช่องเป็นสาธารณะ - ลักษณะ ระบบ สว่าง มืด @@ -127,7 +121,6 @@ สถานะ Tor: %1$s, การบูต %2$d%% ล่าสุด: %1$s ลบข้อมูลฉุกเฉิน - เคล็ดลับ: แตะชื่อแอปสามครั้งเพื่อลบข้อมูลที่บันทึกทั้งหมด – รวมทั้งข้อความ กุญแจ และการตั้งค่า การตั้งค่าดีบัก โอเพนซอร์ส • ความเป็นส่วนตัวมาก่อน • กระจายศูนย์ ปิด @@ -147,7 +140,6 @@ เปิดแผนที่ เอาที่คั่นหน้าออก เทเลพอร์ต - ที่เลือก ออกจากช่อง ติดต่อได้ผ่าน Nostr รายการโปรด (ออฟไลน์) @@ -286,14 +278,12 @@ สิทธิ์ ให้สิทธิ์ bitchat ไม่ติดตามตำแหน่งของคุณ - ผู้คน ไม่มีใครอยู่ใกล้… (คุณ) ลาก/ซูมเพื่อเลือก geohash เลือก พิมพ์ข้อความ… กล่าวถึง - (~%1$s) v%1$s image/* รูปภาพ @@ -307,11 +297,6 @@ เล่น หยุดชั่วคราว - กำลังขุด PoW - เปิดใช้ PoW - Proof of Work - กำลังขุด… - pow: %1$dbit จำเป็นสำหรับการค้นหาผู้ใช้ bitchat ผ่านบลูทูธ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 93888483..b81fdd08 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -63,12 +63,8 @@ Sık kullanılandan kaldır Yer imi ekle - geri - kanal: %1$s - ayrıl Nostr üzerinden ulaşılabilir Okunmamış özel mesaj - Yer imini değiştir Teleport edildi Tor durumu Bağlı eşler @@ -86,14 +82,12 @@ ❌ 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ı 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 @@ -117,7 +111,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 @@ -136,7 +129,6 @@ Haritayı aç Yer imini kaldır Teleport - Seçildi Kanaldan ayrıl Nostr üzerinden erişilebilir Favori (çevrimdışı) @@ -265,7 +257,6 @@ bitchat konumunu takip etmez @ · ⧉ - KİŞİLER yakında kimse yok… (sen) geohash seçmek için kaydır/zoom yap @@ -274,7 +265,6 @@ @%1$s bahset %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 3f1c735a..36c5265d 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -71,12 +71,8 @@ بک مارک شامل کریں - واپس - چینل: %1$s - چھوڑیں Nostr کے ذریعے رسائی غیر پڑھے ہوئے نجی پیغامات - بک مارک تبدیل کریں ٹیلی پورٹ Tor کی کیفیت منسلک پیئرز @@ -96,14 +92,12 @@ تصویر منتخب کریں - غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن آف لائن میش چیٹ انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔ آن لائن geohash چینلز geohash پر مبنی چینلز کے ذریعے اپنے قریب کے لوگوں سے جڑیں۔ عوامی انٹرنیٹ ریلے کے ذریعے میش کو پھیلائیں۔ اینڈ‑ٹو‑اینڈ انکرپشن نجی پیغامات انکرپٹڈ ہیں۔ چینل پیغامات عوامی ہیں۔ - ظاہری شکل سسٹم ہلکا گہرا @@ -127,7 +121,6 @@ Tor کی کیفیت: %1$s، بوٹ اسٹریپ %2$d%% آخری: %1$s ہنگامی ڈیٹا صافی - تجویز: تمام محفوظ شدہ ڈیٹا – پیغامات، کلیدیں اور سیٹنگز سمیت – صاف کرنے کے لیے ایپ کے ٹائٹل پر تین بار ٹیپ کریں۔ ڈیبگ سیٹنگز اوپن سورس • پرائیویسی پہلے • غیر مرکزی بند کریں @@ -147,7 +140,6 @@ نقشہ کھولیں بک مارک ہٹائیں ٹیلی پورٹ - منتخب چینل چھوڑیں Nostr کے ذریعے رسائی پسندیدہ (آف لائن) @@ -286,14 +278,12 @@ اجازات اجازات دیں bitchat آپ کا مقام ٹریک نہیں کرتا - لوگ یہاں کوئی نہیں… (آپ) geohash منتخب کرنے کے لیے پین/زوم کریں منتخب کریں پیغام لکھیں… ذکر - (~%1$s) v%1$s image/* تصویر @@ -307,11 +297,6 @@ چلائیں روکیں - PoW مائننگ - PoW فعال - پروف آف ورک - مائننگ… - pow: %1$dbit بلوٹوتھ کے ذریعے bitchat صارفین کی دریافت کے لیے ضروری diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 4dd4ad37..20028f38 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -71,12 +71,8 @@ Thêm bookmark - 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 - Bật/tắt bookmark Đã dịch chuyển Trạng thái Tor Peers đã kết nối @@ -96,14 +92,12 @@ 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 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 @@ -127,7 +121,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 @@ -147,7 +140,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) @@ -286,14 +278,12 @@ 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 chọn Nhập tin nhắn … nhắc đến - (~%1$s) v%1$s image/* Hình ảnh @@ -307,11 +297,6 @@ Phát Tạm dừng - Đang mining PoW - PoW đã bật - Proof of Work - Đang mining … - pow: %1$dbit Cần thiết để khám phá người dùng bitchat qua Bluetooth diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f8879cc8..d6eada8c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -71,12 +71,8 @@ 添加书签 - 返回 - 频道:%1$s - 离开 可通过 Nostr 联系 未读私信 - 切换书签 已传送 Tor 状态 已连接节点 @@ -96,14 +92,12 @@ 选择图片 - 去中心化 mesh 消息,端到端加密 离线 Mesh 聊天 无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。 在线 Geohash 频道 使用基于 geohash 的频道与附近的人交流。通过公共互联网中继扩展 mesh。 端到端加密 私信加密;频道消息为公开。 - 外观 系统 浅色 深色 @@ -127,7 +121,6 @@ Tor 状态:%1$s,引导 %2$d%% 最近:%1$s 紧急数据删除 - 提示:三击应用标题以删除所有数据,包括消息、密钥和设置。 调试设置 Open Source • Privacy First • 去中心化 关闭 @@ -147,7 +140,6 @@ 打开地图 移除书签 传送 - 已选择 离开频道 可通过 Nostr 联系 收藏(离线) @@ -281,7 +273,6 @@ bitchat 不会跟踪你的位置 @ · ⧉ - 成员 附近无人… (你) 拖动/缩放以选择 geohash @@ -290,7 +281,6 @@ @%1$s 提及 %1$d / %2$d - (~%1$s) @%1$s v%1$s # diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd316e59..02393f74 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,12 +72,8 @@ Add bookmark - back - channel: %1$s - leave Nostr reachable Unread private messages - Toggle bookmark Location notes Teleported Tor status @@ -109,43 +105,75 @@ Pick file - decentralized mesh messaging with end-to-end encryption - 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. + The sidegroup chat + + + Info + Settings + + + 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 + + + No Tracking + No servers, accounts, or data collection. + Ephemeral Identity + A new peer ID is generated regularly. + Triple-tap the logo to instantly clear all data. + + + Tor Network + Difficulty + %1$d bits • %2$s + Connected (%1$d%%) + Disconnected + + 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 - 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 - 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 Privacy Protected - cancel + Cancel Share BitChat @@ -226,10 +254,9 @@ Open map Remove bookmark Teleport - Selected 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 @@ -243,15 +270,11 @@ Link Record voice note Pick media - Mining PoW - PoW Enabled - Proof of Work - mining... - pow: %1$dbit 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 @@ -259,44 +282,61 @@ 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 - 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. - grant location permission - location permission denied. enable in settings to use location channels. + Channels + Location Channels + Chat by coarse location. Never shares exact GPS. + Nearby + Offline mesh. No internet required. - \u2713 location permission granted - checking permissions... - finding nearby channels… - bookmarked + + Nearby + Tor Routing + Hides your IP for location channels. + Recommended + + + People (%1$d) + People + On location + Teleported in + + Grant location permission + Location permission denied. Enable in settings to use location channels. + + \u2713 Location permission granted + Checking permissions… + Finding nearby channels… + Bookmarked geohash - invalid geohash - teleport - disable location services - enable location services + Invalid geohash + Teleport + Remove location access + Enable location services mesh - block - neighborhood - city - province - region + Mesh + Block + Neighborhood + City + Province + Region check for notes left here @@ -307,89 +347,89 @@ #%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 @@ -398,11 +438,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 @@ -418,13 +458,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 . @@ -435,23 +475,21 @@ 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 · ⧉ - PEOPLE - nobody around... + Nobody around… (you) - pan and zoom to select a geohash - select - type a message... + Pan and zoom to select a geohash + Select + Type a message… @%1$s - mention + Mention %1$d / %2$d - (~%1$s) @%1$s v%1$s # 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..e64448c3 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -1,44 +1,558 @@ package com.bitchat.android.ui +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +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.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.colorForPeer import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +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 { + private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } + private val palette = DarkBitchatPalette + private val colorScheme = DarkBitchatColorScheme + + 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, + "00:00 ⛨12b", + 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, + "00:00", + 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, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + ) + + assertEquals("hello there 00:00", body.text) + val timestamp = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00" + }.item + assertEquals(10.sp, timestamp.fontSize) + assertEquals(FontWeight.Normal, timestamp.fontWeight) + assertEquals(palette.textTertiary, timestamp.color) + } + + @Test + fun `body appends PoW badge after the inline timestamp`() { + val body = formatTextMessageBody( + message = message("mined", powDifficulty = 8), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + ) + + assertEquals("mined 00:00 ⛨8b", body.text) + val timestampAndPow = body.spanStyles.first { + body.text.substring(it.start, it.end) == " 00:00 ⛨8b" + }.item + assertEquals(10.sp, timestampAndPow.fontSize) + assertEquals(palette.textTertiary, timestampAndPow.color) + } + + @Test + fun `body can omit the timestamp for callers that render it separately`() { + val body = formatTextMessageBody( + message = message("hello there"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + 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, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val textStyle = body.spanStyles.first { it.start == 0 } + assertEquals(colorScheme.onSurface, 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, + // 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, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + 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, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + 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, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + 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 } + .item + assertEquals(palette.accentOrange, nameStyle.color) + } + + @Test + fun `mention of another user is tinted by that user's own peer color`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val mentionPeerIdentities = buildMentionPeerIdentityMap( + messages = listOf( + BitchatMessage( + sender = "carol#04af", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + ) + val body = formatTextMessageBody( + message = message("cc @carol#04af"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, + timeFormatter = timeFormatter, + includeTimestamp = false, + ) + + val expected = colorForPeer(identity, palette) + val chip = mentionChipSpans(body).single() + assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) + assertTrue(body.spanStyles.any { it.item.color == expected }) + } + + @Test + fun `composer colors nickname and hash suffix from the mentioned peer identity`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val token = "@carol#04af" + val input = "ping $token now" + val transformed = MentionVisualTransformation( + mentionPeerIdentities = mapOf("carol#04af" to identity), + palette = palette, + ).filter(AnnotatedString(input)).text + + val expectedColor = colorForPeer(identity, palette) + val tokenStart = input.indexOf(token) + val suffixStart = input.indexOf("#04af") + val tokenEnd = tokenStart + token.length + + assertEquals(input, transformed.text) + assertTrue(transformed.spanStyles.any { + it.start == tokenStart && + it.end == tokenEnd && + it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA) + }) + assertTrue(transformed.spanStyles.any { + it.start == tokenStart && + it.end == suffixStart && + it.item.color == expectedColor + }) + assertTrue(transformed.spanStyles.any { + it.start == suffixStart && + it.end == tokenEnd && + it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA) + }) + } + + @Test + fun `ambiguous base nickname is not assigned to the wrong peer`() { + val firstIdentity = PeerIdentity.nostr("11111111".repeat(8)) + val secondIdentity = PeerIdentity.nostr("22222222".repeat(8)) + val identities = buildMentionPeerIdentityMap( + messages = emptyList(), + knownPeers = listOf( + "alice#1111" to firstIdentity, + "alice#2222" to secondIdentity, + ) + ) + + assertEquals(firstIdentity, identities["alice#1111"]) + assertEquals(secondIdentity, identities["alice#2222"]) + assertFalse(identities.containsKey("alice")) + assertEquals( + firstIdentity, + resolveMentionPeerIdentity("@alice#1111", identities) + ) + assertEquals(null, resolveMentionPeerIdentity("@alice", identities)) + } + + @Test + fun `message without mentions has no background chips`() { + val body = formatTextMessageBody( + message = message("no mentions in here"), + currentUserNickname = "bob", + palette = palette, + contentColor = colorScheme.onSurface, + linkColor = colorScheme.secondary, + 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"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ).text + + assertEquals("// Tor started. Routing all chats via Tor 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"), + contentColor = colorScheme.onSurface, + timeFormatter = timeFormatter, + ) + + 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"), + contentColor = colorScheme.onSurface, + 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" + }.item + + assertEquals(12.sp, action.fontSize) + assertEquals(FontWeight.Medium, action.fontWeight) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color) + assertEquals(10.sp, time.fontSize) + assertEquals(FontWeight.Normal, time.fontWeight) + assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color) + } + + // 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", + myPeerID = "peer-me", + 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) + 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 + ) + } + + @Test + fun `sender label annotates the nickname for others but not for yourself`() { + val other = formatTextMessageSender( + message = message("hi", sender = "carol#04af"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size) + + val mine = formatTextMessageSender( + message = message("hi", sender = "bob"), + currentUserNickname = "bob", + myPeerID = "peer-me", + palette = palette, + ) + assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty()) + } + + // MARK: - Peer colors + + @Test + fun `peer identity factories normalize stable IDs without resolving UI colors`() { + assertEquals( + PeerIdentity.mesh("abcdef"), + PeerIdentity.mesh("ABCDEF") + ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("ABCDEF") + ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("nostr:nostr_ABCDEF") + ) + assertEquals( + "nostr:nostr:abcdef01", + PeerIdentity.nostr("ABCDEF0123456789").stableKey + ) + assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey) + } + + @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 identity = PeerIdentity.mesh("abc") + val dark = colorForPeer(identity, DarkBitchatPalette) + val light = colorForPeer(identity, LightBitchatPalette) + + 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) + 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 = colorForPeer( + PeerIdentity.mesh("seed$i"), + DarkBitchatPalette + ) + 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 `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 + ) + } + + @Test + fun `geohash chat and people sheet resolve the same full Nostr identity`() { + val pubkey = "ABCDEF0123456789".repeat(4) + val peopleIdentity = PeerIdentity.nostr(pubkey) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + assertEquals( + colorForPeer(peopleIdentity, palette), + colorForPeer(chatIdentity, palette) + ) + } + + @Test + fun `mesh chat and people sheet resolve the same peer identity`() { + val peerID = "ABCDEF0123456789" + val peopleIdentity = PeerIdentity.mesh(peerID) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = peerID, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + } + + @Test + fun `full Nostr identity wins over a truncated routing alias`() { + val pubkey = "0123456789ABCDEF".repeat(4) + val identity = peerIdentityForMessage( + BitchatMessage( + sender = "alice", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr_${pubkey.take(16)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(PeerIdentity.nostr(pubkey), identity) + } + + 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 + } } 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..869ae924 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt @@ -0,0 +1,130 @@ +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 }) + } + + @Test + fun `duplicate nicknames are detected across case and sections`() { + val duplicates = duplicateGeohashBaseNames( + listOf( + person("first", "Alice"), + person("second", "alice"), + person("third", "bob") + ) + ) + + assertEquals(setOf("alice"), duplicates) + } + + @Test + fun `duplicate nickname gets the same last-four ID suffix as chat`() { + assertEquals( + "#cdef", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = true) + ) + assertEquals( + "", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = false) + ) + } + + @Test + fun `existing chat-style suffix is preserved`() { + assertEquals( + "#04af", + geohashIdentitySuffix(person("0123456789abcdef", "alice#04af"), showHashSuffix = true) + ) + } + + @Test + fun `disambiguated display name matches the mention token used by chat`() { + val alice = person("0123456789abcdef", "alice") + + assertEquals( + "alice#cdef", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = setOf("alice")) + ) + assertEquals( + "alice", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = emptySet()) + ) + } +} 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/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt new file mode 100644 index 00000000..607aaf1e --- /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(::isUnannouncedNickname)) + } + + @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(isUnannouncedNickname("anon")) + assertTrue(isUnannouncedNickname("anon#04af")) + assertFalse(isUnannouncedNickname("anon1234#04af")) + assertFalse(isUnannouncedNickname("anonymous#04af")) + assertFalse(isUnannouncedNickname("anonracer")) + } + + @Test + fun `mention popup viewport is capped at five rows`() { + assertEquals(5, MaxVisibleMentionSuggestions) + } +} 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..fb534e69 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt @@ -0,0 +1,186 @@ +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 `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() + 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) + } +} 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..eea760f7 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt @@ -0,0 +1,154 @@ +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 `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(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) = + copy(timestamp = Date(base + offsetMs)) +}