From 8e849a7bbea1dd0381c7735c6ae0bd735e591bd1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:14:54 +0200 Subject: [PATCH] fixes --- .../ui/component/button/BitChatBrandButton.kt | 9 +- .../ui/component/sheet/BitchatBottomSheet.kt | 42 ++++- .../ui/component/sheet/BitchatSheetTopBar.kt | 7 +- .../java/com/bitchat/android/ui/AboutSheet.kt | 45 +++++- .../bitchat/android/ui/AnimatedRowColumn.kt | 85 +++++++++++ .../java/com/bitchat/android/ui/ChatHeader.kt | 68 ++++++--- .../bitchat/android/ui/GeohashPeopleList.kt | 143 +++++++++++++----- .../android/ui/LocationChannelsSheet.kt | 74 ++++++++- .../bitchat/android/ui/LocationNotesButton.kt | 2 + .../bitchat/android/ui/MeshPeerListSheet.kt | 36 +++-- .../com/bitchat/android/ui/PressFeedback.kt | 64 ++++++++ .../android/ui/SecurityVerificationSheet.kt | 4 +- .../bitchat/android/ui/VerificationSheet.kt | 4 +- app/src/main/res/values/strings.xml | 1 + .../android/ui/GeohashAnonOrderingTest.kt | 113 ++++++++++++++ 15 files changed, 606 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/PressFeedback.kt create mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt index c1361364..f4afd5b6 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt @@ -12,11 +12,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bitchat.android.core.ui.icon.BitChatIcon +import com.bitchat.android.ui.rememberPressScale import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -39,6 +42,9 @@ fun BitChatBrandButton( val currentOnClick by rememberUpdatedState(onClick) val currentOnTripleClick by rememberUpdatedState(onTripleClick) + val interactionSource = remember { MutableInteractionSource() } + val pressScale = rememberPressScale(interactionSource) + IconButton( onClick = { tapCount += 1 @@ -59,7 +65,8 @@ fun BitChatBrandButton( } } }, - modifier = modifier, + modifier = modifier.scale(pressScale), + interactionSource = interactionSource, ) { Icon( imageVector = BitChatIcon, diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt index 948fd6d9..2b14c09e 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt @@ -9,8 +9,26 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first. + * + * `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap + * on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from + * a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the + * composable out of the tree and makes the sheet vanish instantly. + * + * Anything inside a sheet that wants to close it should prefer this over calling its own + * `onDismiss` directly. + */ +val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null } @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -20,6 +38,21 @@ fun BitchatBottomSheet( onDismissRequest: () -> Unit, content: @Composable (ColumnScope.() -> Unit), ) { + val scope = rememberCoroutineScope() + + // Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()` + // throws if the sheet is already on its way out (two rapid taps on a close button), which is + // benign — the dismissal still has to go through. + val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) { + { + scope.launch { + runCatching { sheetState.hide() } + onDismissRequest() + } + Unit + } + } + ModalBottomSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismissRequest, @@ -27,6 +60,9 @@ fun BitchatBottomSheet( dragHandle = null, shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), containerColor = MaterialTheme.colorScheme.background, - content = content, - ) -} \ No newline at end of file + ) { + CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) { + content() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt index 036cf01b..333e3ae6 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider import com.bitchat.android.core.ui.component.button.CloseButton @OptIn(ExperimentalMaterial3Api::class) @@ -30,8 +31,9 @@ fun BitchatSheetTopBar( navigationIcon = { navigationIcon?.invoke() }, actions = { actions() + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onClose, + onClick = { dismiss?.invoke() ?: onClose() }, modifier = Modifier.padding(horizontal = 16.dp) ) }, @@ -59,8 +61,9 @@ fun BitchatSheetCenterTopBar( navigationIcon = { navigationIcon?.invoke() }, actions = { actions() + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onClose, + onClick = { dismiss?.invoke() ?: onClose() }, modifier = Modifier.padding(horizontal = 16.dp) ) }, diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 6be3cff1..821ccabd 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -5,6 +5,8 @@ import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Speed import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -30,6 +32,7 @@ import com.bitchat.android.nostr.PoWPreferenceManager import androidx.compose.ui.res.stringResource import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager @@ -101,17 +104,43 @@ private fun SettingsToggleRow( ) { val colorScheme = MaterialTheme.colorScheme val palette = LocalBitchatPalette.current + val interactionSource = remember { MutableInteractionSource() } + + // Colours cross-fade so a row becoming available (Tor finishing bootstrap) eases in rather + // than popping. + val iconTint by animateColorAsState( + targetValue = if (enabled) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowIcon" + ) + val titleColor by animateColorAsState( + targetValue = if (enabled) palette.textPrimary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowTitle" + ) + val subtitleColor by animateColorAsState( + targetValue = if (enabled) palette.textSecondary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "settingsRowSubtitle" + ) Row( modifier = Modifier .fillMaxWidth() + // The whole row toggles, not just the switch: a 14.dp-tall switch is a poor target + // when there is a full-width row sitting right next to it. + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled + ) { onCheckedChange(!checked) } .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = icon, contentDescription = null, - tint = if (enabled) colorScheme.primary else palette.textTertiary, + tint = iconTint, modifier = Modifier.size(22.dp) ) @@ -130,7 +159,7 @@ private fun SettingsToggleRow( fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = if (enabled) palette.textPrimary else palette.textTertiary + color = titleColor ) statusIndicator?.invoke() } @@ -138,7 +167,7 @@ private fun SettingsToggleRow( text = subtitle, fontFamily = FontFamily.Monospace, fontSize = 12.sp, - color = if (enabled) palette.textSecondary else palette.textTertiary, + color = subtitleColor, lineHeight = 17.sp ) } @@ -149,6 +178,7 @@ private fun SettingsToggleRow( checked = checked, onCheckedChange = { if (enabled) onCheckedChange(it) }, enabled = enabled, + interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, checkedTrackColor = palette.accentGreen, @@ -416,7 +446,8 @@ fun AboutSheet( fontWeight = FontWeight.Medium, color = palette.textPrimary ) - Text( + AnimatedCountLabel( + count = powDifficulty, text = stringResource( R.string.about_difficulty_value, powDifficulty, @@ -440,7 +471,8 @@ fun AboutSheet( ) ) - Text( + AnimatedCountLabel( + count = powDifficulty, text = when { powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) @@ -560,8 +592,9 @@ fun AboutSheet( .height(64.dp) .background(palette.background.copy(alpha = topBarAlpha)) ) { + val dismiss = LocalSheetDismiss.current CloseButton( - onClick = onDismiss, + onClick = { dismiss?.invoke() ?: onDismiss() }, modifier = modifier .align(Alignment.CenterEnd) .padding(horizontal = 16.dp), diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt new file mode 100644 index 00000000..26c8259a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt @@ -0,0 +1,85 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LookaheadScope + +/** + * Motion for a row moving to a new position, or resizing in place. + * + * People lists reorder themselves constantly and without user input — someone sends a DM and jumps + * to the top, a peer drops off the mesh, a favourite comes online. Rows teleporting between + * positions makes the list feel unreliable and costs the reader their place in it. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +private val RowBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} + +/** Entry fade for a row that was not previously in the list. */ +private val RowEnterSpec: FiniteAnimationSpec = + spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 900f) + +/** + * A vertical list whose rows animate when they are added, removed, or reordered. + * + * Deliberately not a `LazyColumn`: both people lists live *inside* an outer `LazyColumn` item, where + * nesting another lazy list is not possible. [LookaheadScope] plus [Modifier.animateBounds] gives + * the same reorder-and-resize animation that `LazyItemScope.animateItem` provides, without the list + * needing to be lazy. These lists are bounded (and the geohash one is explicitly capped), so + * nothing is lost by composing every row. + * + * Rows are keyed so identity survives reordering. Without stable keys a row that moved would look + * like a different row appearing, and would fade instead of sliding. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun AnimatedRowColumn( + items: List, + key: (T) -> Any, + modifier: Modifier = Modifier, + row: @Composable (index: Int, item: T) -> Unit +) { + LookaheadScope { + Column(modifier = modifier) { + items.forEachIndexed { index, item -> + key(key(item)) { + // Fades the row in on the composition it first appears, then never again — + // reordering an existing row must slide, not blink. + val enter = remember { Animatable(0f) } + LaunchedEffect(Unit) { enter.animateTo(1f, RowEnterSpec) } + + Box( + modifier = Modifier + .animateBounds( + lookaheadScope = this@LookaheadScope, + boundsTransform = RowBoundsTransform + ) + .graphicsLayer { alpha = enter.value } + ) { + row(index, item) + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index d2e6a9b6..fad7b82e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -27,20 +27,21 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.res.stringResource -import com.bitchat.android.R +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.BitChatBrandButton import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorMode @@ -96,7 +97,7 @@ private fun HeaderIconButton( modifier = modifier .size(HeaderTapTarget) .clip(CircleShape) - .clickable(onClickLabel = contentDescription) { onClick() }, + .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription), contentAlignment = Alignment.Center ) { content() @@ -167,14 +168,27 @@ internal fun TorAwareHeaderIcon( 1f } - Box(contentAlignment = Alignment.Center, modifier = modifier) { + // Fixed layout footprint = icon size. Glow is drawn larger via requiredSize so it never + // pushes neighbouring text when the pulse starts/stops. + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(HeaderIconSize) + ) { if (isProgress) { - // Soft halo behind the glyph — opacity tracks the pulse, never solid. + val glowBrush = remember(tint) { + Brush.radialGradient( + colorStops = arrayOf( + 0.0f to tint.copy(alpha = 0.55f), + 0.45f to tint.copy(alpha = 0.22f), + 1.0f to Color.Transparent, + ) + ) + } Box( modifier = Modifier - .size(HeaderIconSize + 10.dp) - .graphicsLayer { alpha = pulse * 0.28f } - .background(tint.copy(alpha = 1f), CircleShape) + .requiredSize(HeaderIconSize + 14.dp) + .graphicsLayer { alpha = pulse * 0.85f } + .background(glowBrush) ) } Icon( @@ -183,7 +197,6 @@ internal fun TorAwareHeaderIcon( modifier = Modifier .size(HeaderIconSize) .graphicsLayer { - // Pulse icon opacity gently when in progress; settle to full when idle. alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f }, tint = tint @@ -323,7 +336,7 @@ fun PeerCounter( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier .clip(HeaderClusterShape) - .clickable { onClick() } + .pressScaleClickable(onClick = onClick) .height(HeaderTapTarget) .padding(horizontal = 6.dp) ) { @@ -438,7 +451,7 @@ private fun ChannelHeader( modifier = Modifier .align(Alignment.Center) .clip(HeaderClusterShape) - .clickable { onSidebarClick() } + .pressScaleClickable(onClick = onSidebarClick) .padding(horizontal = 8.dp, vertical = 4.dp) ) @@ -498,20 +511,27 @@ private fun MainHeader( modifier = Modifier.size(HeaderTapTarget), ) - Text( - text = "/", - style = MaterialTheme.typography.bodyMedium, - fontSize = HeaderTextSize, - // Dimmed: the slash is a separator, not content. At full brightness it competed - // with the nickname beside it. - color = colorScheme.primary.copy(alpha = 0.45f), - modifier = Modifier.padding(horizontal = 2.dp) - ) + // Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than + // spacing between the mark and the path label. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.offset(x = (-6).dp) + ) { + Text( + text = "/", + style = MaterialTheme.typography.bodyMedium, + fontSize = HeaderTextSize, + // Dimmed: the slash is a separator, not content. At full brightness it competed + // with the nickname beside it. + color = colorScheme.primary.copy(alpha = 0.45f), + modifier = Modifier.padding(end = 2.dp) + ) - NicknameEditor( - value = nickname, - onValueChange = onNicknameChange - ) + NicknameEditor( + value = nickname, + onValueChange = onNicknameChange + ) + } } // MARK: - Status cluster. diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 58f2f44a..75fe577c 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -5,12 +5,18 @@ import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -19,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -92,14 +99,15 @@ fun GeohashPeopleList( } } + // Self first, then anyone who chose a nickname, then the anons — all by recency + // within their group. A busy geohash is mostly anonymous drive-by participants, and + // letting them sort by recency alone buried the handful of people worth recognising. val orderedPeople = remember(geohashPeople, myHex) { - geohashPeople.sortedWith { a, b -> - when { - myHex != null && a.id == myHex && b.id != myHex -> -1 - myHex != null && b.id == myHex && a.id != myHex -> 1 - else -> b.lastSeen.compareTo(a.lastSeen) - } - } + geohashPeople.sortedWith( + compareByDescending { myHex != null && it.id == myHex } + .thenBy { it.isAnonymous() } + .thenByDescending { it.lastSeen } + ) } val baseNameCounts = remember(geohashPeople) { @@ -145,21 +153,7 @@ fun GeohashPeopleList( icon = Icons.Outlined.LocationOn, title = stringResource(R.string.section_on_location) ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - localPeople.forEachIndexed { index, person -> - if (index > 0) SheetCardDivider() - personRow(person) - } - } - } + PeopleCard(people = localPeople, row = { personRow(it) }) } if (teleportedPeople.isNotEmpty()) { @@ -168,22 +162,103 @@ fun GeohashPeopleList( title = stringResource(R.string.section_teleported_in), modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp) ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AboutHorizontalPadding) - .padding(top = 10.dp), - color = palette.surface, - shape = AboutCardShape - ) { - Column { - teleportedPeople.forEachIndexed { index, person -> - if (index > 0) SheetCardDivider() - personRow(person) + PeopleCard(people = teleportedPeople, row = { personRow(it) }) + } + } + } +} + +/** Anonymous participants beyond this many are hidden behind the "n more" affordance. */ +internal const val MaxVisibleAnons = 5 + +/** + * Whether this participant never set a nickname. + * + * The app labels them `anon` or `anon1234` before the `#abcd` disambiguator, so the base name is + * what identifies them. + */ +internal fun GeoPerson.isAnonymous(): Boolean { + val base = splitSuffix(displayName).first + return base == "anon" || (base.startsWith("anon") && base.drop(4).all { it.isDigit() }) +} + +/** + * One grouped card of people, with a cap on how many anonymous participants are shown. + * + * A popular geohash can hold dozens of anons, which pushed everyone worth recognising off screen and + * turned the sheet into a wall of near-identical rows. Named participants are always listed in full; + * anons are trimmed to [MaxVisibleAnons], and the overflow is collapsed behind a count. The last + * visible anon fades out under a gradient so the truncation is legible as truncation rather than + * looking like the list simply ended. + */ +@Composable +private fun PeopleCard( + people: List, + row: @Composable (GeoPerson) -> Unit +) { + val palette = LocalBitchatPalette.current + + val named = people.filterNot { it.isAnonymous() } + val anons = people.filter { it.isAnonymous() } + val visibleAnons = anons.take(MaxVisibleAnons) + val hiddenAnonCount = anons.size - visibleAnons.size + val visible = named + visibleAnons + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AboutHorizontalPadding) + .padding(top = 10.dp), + color = palette.surface, + shape = AboutCardShape + ) { + Column { + AnimatedRowColumn(items = visible, key = { it.id }) { index, person -> + Column { + if (index > 0) SheetCardDivider() + if (hiddenAnonCount > 0 && index == visible.lastIndex) { + // Fade only the final row, so the gradient reads as "the list continues" + // rather than dimming content that is still meant to be read. + Box { + row(person) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + listOf( + palette.surface.copy(alpha = 0f), + palette.surface.copy(alpha = 0.85f) + ) + ) + ) + ) } + } else { + row(person) } } } + + AnimatedVisibility( + visible = hiddenAnonCount > 0, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + ) { + Text( + text = stringResource(R.string.people_n_more, hiddenAnonCount), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = palette.textTertiary, + modifier = Modifier + .fillMaxWidth() + .padding( + start = SheetRowHorizontal, + end = SheetRowHorizontal, + bottom = SheetRowVertical + ) + ) + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 28ecd8a0..84ba3709 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1,5 +1,14 @@ package com.bitchat.android.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Hub @@ -43,6 +52,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.GeohashBookmarksStore @@ -54,6 +64,7 @@ import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -68,6 +79,14 @@ private val ChannelDividerInset = SheetRowDividerInset /** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */ private val ChannelSelectedDot = SheetRowSelectedDot +/** + * Pause between applying a channel selection and dismissing the sheet. + * + * Just enough for the active dot to land on the chosen row, so the tap is acknowledged rather than + * answered by the sheet simply disappearing. + */ +private const val SelectionConfirmDelayMs = 180L + /** * Location Channels sheet: grouped card rows matching About → Settings. */ @@ -141,6 +160,18 @@ fun LocationChannelsSheet( onDismissRequest = onDismiss, sheetState = sheetState, ) { + // Selection is applied immediately so the active dot snaps to the new row, then the + // sheet slides away after a beat. Long enough to register the change, short enough + // that it never feels like waiting. + val animatedDismiss = LocalSheetDismiss.current + val confirmSelectionThenDismiss: () -> Unit = { + coroutineScope.launch { + delay(SelectionConfirmDelayMs) + animatedDismiss?.invoke() ?: onDismiss() + } + Unit + } + Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, @@ -324,9 +355,22 @@ fun LocationChannelsSheet( } } - if (customError != null) { + AnimatedVisibility( + visible = customError != null, + enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) + + expandVertically( + tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing) + ), + exit = fadeOut(tween(BitchatMotion.QUICK_MS)) + + shrinkVertically( + tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing) + ) + ) { + // Held across the exit animation: by the time it plays, the error + // itself has already been cleared. + val shownError = remember(customError) { customError ?: "" } Text( - text = customError!!, + text = shownError, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = palette.accentRed, @@ -658,7 +702,24 @@ private fun CustomGeohashRow( val palette = LocalBitchatPalette.current val normalized = customGeohash.trim().lowercase().replace("#", "") val isValid = validateGeohash(normalized) - val teleportColor = if (isValid) colorScheme.primary else palette.textTertiary + // Typing the last character of a valid geohash arms the button; cross-fading both the label + // and its container makes that the moment the row confirms the input is usable. + val teleportColor by animateColorAsState( + targetValue = if (isValid) colorScheme.primary else palette.textTertiary, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportLabel" + ) + val teleportContainer by animateColorAsState( + targetValue = if (isValid) { + colorScheme.primary.copy(alpha = 0.16f) + } else { + palette.surfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "teleportContainer" + ) + val teleportInteraction = remember { MutableInteractionSource() } + val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f) Row( modifier = Modifier @@ -718,7 +779,8 @@ private fun CustomGeohashRow( Box( modifier = Modifier .size(36.dp) - .clickable(onClick = onOpenMap), + .clip(CircleShape) + .pressScaleClickable(onClick = onOpenMap), contentAlignment = Alignment.Center ) { Icon( @@ -733,7 +795,9 @@ private fun CustomGeohashRow( onClick = onTeleport, enabled = isValid, shape = RoundedCornerShape(8.dp), - color = palette.surfaceVariant + color = teleportContainer, + interactionSource = teleportInteraction, + modifier = Modifier.scale(teleportScale) ) { Text( text = stringResource(R.string.teleport).uppercase(), diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 7c29a93a..ab14a999 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -12,7 +12,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 026292fc..cfda3952 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -448,11 +448,21 @@ fun PeopleSection( if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 } - var peerIndex = 0 + // Every row this card will show, in final order, so the animated list can key on identity + // and animate reordering. Offline favourites are appended after the connected peers. + // Collected once for the whole card rather than once per row. + val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() - sortedPeers.forEach { peerID -> - if (peerIndex > 0) SheetCardDivider() - peerIndex++ + val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) } + val rowKeys: List = sortedPeers + + offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) } + + AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey -> + Column { + if (rowIndex > 0) SheetCardDivider() + val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey } + if (connectedPeerForRow != null) { + val peerID = connectedPeerForRow val conversationID = ContactDirectory.canonicalConversationId(peerID) val isFavorite = peerFavoriteStates[peerID] ?: false val isVerified = peerVerifiedStates[peerID] ?: false @@ -472,7 +482,6 @@ fun PeopleSection( val (bName, _) = splitSuffix(displayName) val showHash = (baseNameCounts[bName] ?: 0) > 1 - val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } PeerItem( peerID = peerID, @@ -494,15 +503,12 @@ fun PeopleSection( showNostrGlobe = false, showHashSuffix = showHash ) - } - - // Append offline favorites we actively favorite (and not currently connected) - offlineFavorites.forEach { fav -> - val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey) - if (isFavoriteMappedToConnected(fav)) return@forEach - - if (peerIndex > 0) SheetCardDivider() - peerIndex++ + } else { + // Offline favourite: still worth showing, reachable over Nostr. + val fav = offlineFavoriteRows.first { + ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) == rowKey + } + val favPeerID = rowKey val nostrConvKey: String? = try { FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) @@ -546,6 +552,8 @@ fun PeopleSection( showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), showHashSuffix = showHash ) + } + } } } } diff --git a/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt new file mode 100644 index 00000000..01c9c6ca --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale + +/** + * Press feedback for the app's icon buttons. + * + * Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost + * nothing to show. A brief scale dip is legible on any background and reads as physical. Springs + * rather than tweens, so releasing overshoots very slightly instead of stopping dead. + */ +@Composable +fun rememberPressScale( + interactionSource: InteractionSource, + pressedScale: Float = 0.86f +): Float { + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) pressedScale else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ), + label = "pressScale" + ) + return scale +} + +/** + * Convenience wrapper for the common case: a clickable that scales while held. + * + * Returns the modifier chain to apply, having disabled the default indication — the scale *is* the + * indication, and a ripple underneath it just muddies the edges of these small glyphs. + */ +@Composable +fun Modifier.pressScaleClickable( + onClick: () -> Unit, + enabled: Boolean = true, + onClickLabel: String? = null, + pressedScale: Float = 0.86f +): Modifier { + val interactionSource = remember { MutableInteractionSource() } + val scale = rememberPressScale(interactionSource, pressedScale) + return this + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClickLabel = onClickLabel, + onClick = onClick + ) + .scale(scale) +} diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt index 5549f43f..5d3af681 100644 --- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet private data class SecurityStatusInfo( @@ -161,7 +162,8 @@ private fun SecurityVerificationHeader( color = accent ) Spacer(modifier = Modifier.weight(1f)) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index a81938d5..76aed7d8 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -72,6 +72,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.core.ui.component.button.CloseButton +import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.services.VerificationService import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -235,7 +236,8 @@ private fun VerificationHeader( fontFamily = FontFamily.Monospace, color = accent ) - CloseButton(onClick = onClose) + val dismiss = LocalSheetDismiss.current + CloseButton(onClick = { dismiss?.invoke() ?: onClose() }) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0987249..05d99721 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -256,6 +256,7 @@ People (%1$d) On location Teleported in + %1$d more\u2026 Grant location permission Location permission denied. Enable in settings to use location channels. diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt new file mode 100644 index 00000000..8a00d1c8 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt @@ -0,0 +1,113 @@ +package com.bitchat.android.ui + +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A busy geohash is mostly anonymous drive-by participants. If they sort by recency alongside + * everyone else they bury the handful of people worth recognising, so they are pushed to the end of + * the list and capped. + */ +class GeohashAnonOrderingTest { + + private fun person(name: String, secondsAgo: Long = 0) = GeoPerson( + id = "id-$name", + displayName = name, + lastSeen = Date(1_000_000L - secondsAgo * 1000L) + ) + + @Test + fun `bare anon is anonymous`() { + assertTrue(person("anon").isAnonymous()) + assertTrue(person("anon#04af").isAnonymous()) + } + + @Test + fun `numbered anon is anonymous`() { + assertTrue(person("anon7674").isAnonymous()) + assertTrue(person("anon7674#df5b").isAnonymous()) + } + + @Test + fun `a chosen nickname is not anonymous`() { + assertFalse(person("alice").isAnonymous()) + assertFalse(person("alice#1234").isAnonymous()) + } + + @Test + fun `a nickname that merely starts with anon is not anonymous`() { + // "anonymous" and "anonracer" are deliberate names, not the generated placeholder. + assertFalse(person("anonymous").isAnonymous()) + assertFalse(person("anonracer#04af").isAnonymous()) + } + + @Test + fun `named participants sort ahead of anons regardless of recency`() { + val me = person("me") + val people = listOf( + person("anon1", secondsAgo = 0), // most recent of all + person("zoe", secondsAgo = 500), // stalest named user + person("anon2", secondsAgo = 10), + person("alice", secondsAgo = 200) + ) + + val ordered = (people + me).sortedWith( + compareByDescending { it.id == me.id } + .thenBy { it.isAnonymous() } + .thenByDescending { it.lastSeen } + ) + + assertEquals( + listOf("me", "alice", "zoe", "anon1", "anon2"), + ordered.map { it.displayName } + ) + } + + @Test + fun `recency still orders within each group`() { + val people = listOf( + person("bob", secondsAgo = 100), + person("alice", secondsAgo = 10), + person("anon9", secondsAgo = 300), + person("anon1", secondsAgo = 5) + ) + + val ordered = people.sortedWith( + compareBy { it.isAnonymous() }.thenByDescending { it.lastSeen } + ) + + assertEquals( + listOf("alice", "bob", "anon1", "anon9"), + ordered.map { it.displayName } + ) + } + + @Test + fun `named participants are never trimmed`() { + val named = (1..20).map { person("user$it") } + assertEquals(20, named.filterNot { it.isAnonymous() }.size) + } + + @Test + fun `anons beyond the cap are counted as hidden`() { + val people = (1..12).map { person("anon$it") } + person("alice") + + val anons = people.filter { it.isAnonymous() } + val visible = anons.take(MaxVisibleAnons) + + assertEquals(MaxVisibleAnons, visible.size) + assertEquals(12 - MaxVisibleAnons, anons.size - visible.size) + } + + @Test + fun `a short anon list is shown in full with nothing hidden`() { + val people = listOf(person("alice"), person("anon1"), person("anon2")) + + val anons = people.filter { it.isAnonymous() } + assertEquals(2, anons.take(MaxVisibleAnons).size) + assertEquals(0, anons.size - anons.take(MaxVisibleAnons).size) + } +}