This commit is contained in:
callebtc 2026-07-27 04:14:54 +02:00
parent c0b1af3d0f
commit 8e849a7bbe
15 changed files with 606 additions and 91 deletions

View File

@ -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,

View File

@ -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,
)
}
) {
CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) {
content()
}
}
}

View File

@ -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)
)
},

View File

@ -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),

View File

@ -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<Float> =
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 <T> AnimatedRowColumn(
items: List<T>,
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)
}
}
}
}
}
}

View File

@ -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.

View File

@ -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<GeoPerson> { 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<GeoPerson>,
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
)
)
}
}
}
}

View File

@ -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(),

View File

@ -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

View File

@ -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<String> = 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
)
}
}
}
}
}

View File

@ -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)
}

View File

@ -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() })
}
}

View File

@ -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() })
}
}

View File

@ -256,6 +256,7 @@
<string name="people_count_title">People (%1$d)</string>
<string name="section_on_location">On location</string>
<string name="section_teleported_in">Teleported in</string>
<string name="people_n_more">%1$d more\u2026</string>
<string name="grant_location_permission">Grant location permission</string>
<string name="location_permission_denied">Location permission denied. Enable in settings to use location channels.</string>

View File

@ -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<GeoPerson> { 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<GeoPerson> { 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)
}
}