mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
fixes
This commit is contained in:
parent
c0b1af3d0f
commit
8e849a7bbe
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
},
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
64
app/src/main/java/com/bitchat/android/ui/PressFeedback.kt
Normal file
64
app/src/main/java/com/bitchat/android/ui/PressFeedback.kt
Normal 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)
|
||||
}
|
||||
@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user