From c1c7704c32f8d35a8251c1e59fdf833d3e19fe07 Mon Sep 17 00:00:00 2001
From: callebtc <93376500+callebtc@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:46:11 +0200
Subject: [PATCH] grouping of geohash channel list
---
.../design-icons/on-location-person.svg | 5 +
.../core/ui/component/button/CloseButton.kt | 5 +-
.../com/bitchat/android/ui/ChatUIUtils.kt | 10 +
.../bitchat/android/ui/CommandProcessor.kt | 7 +-
.../bitchat/android/ui/GeohashPeopleList.kt | 314 +++++++-----------
.../drawable/ic_spec_on_location_person.xml | 13 +
app/src/main/res/values/strings.xml | 4 +-
.../android/ui/GeohashAnonOrderingTest.kt | 188 -----------
.../android/ui/GeohashPresenceGroupingTest.kt | 83 +++++
.../android/ui/MentionSuggestionsTest.kt | 12 +-
10 files changed, 249 insertions(+), 392 deletions(-)
create mode 100644 app/src/main/assets/design-icons/on-location-person.svg
create mode 100644 app/src/main/res/drawable/ic_spec_on_location_person.xml
delete mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt
create mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt
diff --git a/app/src/main/assets/design-icons/on-location-person.svg b/app/src/main/assets/design-icons/on-location-person.svg
new file mode 100644
index 00000000..5b448ae9
--- /dev/null
+++ b/app/src/main/assets/design-icons/on-location-person.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt
index 36ad7526..091f93fc 100644
--- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt
+++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt
@@ -8,6 +8,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
@@ -24,8 +25,8 @@ fun CloseButton(
// 44.dp to match every other tap target in the app's chrome.
modifier = modifier.size(44.dp),
colors = IconButtonDefaults.iconButtonColors(
- contentColor = palette.textSecondary,
- containerColor = palette.surfaceVariant
+ contentColor = palette.accentGreen,
+ containerColor = Color.Transparent
)
) {
Icon(
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
index 5f5fa778..05425f60 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
@@ -373,6 +373,16 @@ fun splitSuffix(name: String): Pair {
return Pair(name, "")
}
+/**
+ * A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport
+ * may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as
+ * `anon1234`, `anonymous`, and `anonracer` are real announced usernames.
+ */
+internal fun isUnannouncedNickname(displayName: String): Boolean {
+ val base = splitSuffix(displayName.trim()).first
+ return base.equals("anon", ignoreCase = true)
+}
+
/**
* iOS-style content formatting with proper hashtag and mention handling.
*
diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
index 04c0b0d7..4ac9344a 100644
--- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
+++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
@@ -546,14 +546,9 @@ internal fun filterMentionCandidates(
return candidates.asSequence()
.map(String::trim)
.filter(String::isNotEmpty)
- .filterNot(::isUnannouncedMentionNickname)
+ .filterNot(::isUnannouncedNickname)
.filter { nickname -> nickname.startsWith(query, ignoreCase = true) }
.distinctBy { nickname -> nickname.lowercase(Locale.ROOT) }
.sortedWith(String.CASE_INSENSITIVE_ORDER)
.toList()
}
-
-internal fun isUnannouncedMentionNickname(displayName: String): Boolean {
- val base = splitSuffix(displayName.trim()).first
- return base.equals("anon", ignoreCase = true)
-}
diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
index 0efab46f..7610fedf 100644
--- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
+++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
@@ -2,23 +2,13 @@ package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
-import androidx.compose.material.icons.outlined.Explore
-import androidx.compose.material.icons.outlined.HelpOutline
-import androidx.compose.material.icons.outlined.Person
-import androidx.compose.material.icons.outlined.LocationOn
import android.util.Log
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -28,7 +18,6 @@ import androidx.compose.ui.res.painterResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
-import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.util.*
@@ -48,22 +37,74 @@ fun GeohashPeopleList(
onTapPerson: () -> Unit,
modifier: Modifier = Modifier
) {
- val colorScheme = MaterialTheme.colorScheme
-
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
+ val teleportedGeo by viewModel.teleportedGeo.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val palette = LocalBitchatPalette.current
+ val myHex = remember(selectedLocationChannel) {
+ when (val channel = selectedLocationChannel) {
+ is com.bitchat.android.geohash.ChannelID.Location -> {
+ try {
+ val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(
+ forGeohash = channel.channel.geohash,
+ context = viewModel.getApplication()
+ )
+ identity.publicKeyHex.lowercase(Locale.ROOT)
+ } catch (e: Exception) {
+ Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}")
+ null
+ }
+ }
+ else -> null
+ }
+ }
+ val peopleIncludingSelf = remember(geohashPeople, myHex, nickname) {
+ if (myHex != null && geohashPeople.none { it.id.equals(myHex, ignoreCase = true) }) {
+ listOf(
+ GeoPerson(
+ id = myHex,
+ displayName = nickname.ifBlank { "anon" },
+ lastSeen = Date(0)
+ )
+ ) + geohashPeople
+ } else {
+ geohashPeople
+ }
+ }
+ val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) {
+ sectionGeohashPeople(
+ people = peopleIncludingSelf,
+ myId = myHex,
+ selfIsTeleported = isTeleported,
+ teleportedIds = teleportedGeo
+ )
+ }
+ val displayedPeople = remember(sections) {
+ sections.onLocation + sections.teleportedIn
+ }
+ val teleportedPersonIds = remember(sections.teleportedIn) {
+ sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) }
+ }
+ val baseNameCounts = remember(displayedPeople) {
+ buildMap {
+ displayedPeople.forEach { person ->
+ val baseName = splitSuffix(person.displayName).first
+ put(baseName, (get(baseName) ?: 0) + 1)
+ }
+ }
+ }
Column(modifier = modifier) {
- if (geohashPeople.isEmpty()) {
- SheetIconSectionHeader(
- iconRes = R.drawable.ic_spec_people,
- title = stringResource(R.string.section_people)
- )
+ SheetIconSectionHeader(
+ iconRes = R.drawable.ic_spec_people,
+ title = stringResource(R.string.people_count_title, displayedPeople.size)
+ )
+
+ if (displayedPeople.isEmpty()) {
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -84,77 +125,23 @@ fun GeohashPeopleList(
)
}
} else {
- val myHex = remember(selectedLocationChannel) {
- when (val channel = selectedLocationChannel) {
- is com.bitchat.android.geohash.ChannelID.Location -> {
- try {
- val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(
- forGeohash = channel.channel.geohash,
- context = viewModel.getApplication()
- )
- identity.publicKeyHex.lowercase()
- } catch (e: Exception) {
- Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}")
- null
- }
- }
- else -> null
- }
- }
-
- // Self first, then anyone who chose a nickname, then the anons — all by recency
- // within their group. A busy geohash is mostly anonymous drive-by participants, and
- // letting them sort by recency alone buried the handful of people worth recognising.
- val orderedPeople = remember(geohashPeople, myHex) {
- geohashPeople.sortedWith(
- compareByDescending { myHex != null && it.id == myHex }
- .thenBy { it.isAnonymous() }
- .thenByDescending { it.lastSeen }
- )
- }
-
- val baseNameCounts = remember(geohashPeople) {
- val counts = mutableMapOf()
- geohashPeople.forEach { person ->
- val (b, _) = splitSuffix(person.displayName)
- counts[b] = (counts[b] ?: 0) + 1
- }
- counts
- }
-
- fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) {
- isTeleported
- } else {
- viewModel.isPersonTeleported(person.id)
- }
-
- // Two groups: peers who announced a nickname, then the anons.
- //
- // A busy geohash is mostly anonymous drive-by participants, and mixing them in pushed
- // the few recognisable names out of view. Teleport state is not a grouping any more —
- // it is already on every row as its own glyph, so splitting "on location" from
- // "teleported in" only fragmented the short list that people actually read.
- //
- // Self is never grouped as an anon even when unnamed: you always want to find yourself
- // among the people, not buried at the bottom.
- val isSelf: (GeoPerson) -> Boolean = { myHex != null && it.id == myHex }
- val namedPeople = orderedPeople.filter { isSelf(it) || !it.isAnonymous() }
- val anonPeople = orderedPeople.filter { !isSelf(it) && it.isAnonymous() }
-
@Composable
fun personRow(person: GeoPerson) {
+ val isMe = myHex != null && person.id.equals(myHex, ignoreCase = true)
+ val personIsTeleported = if (isMe) {
+ isTeleported
+ } else {
+ person.id.lowercase(Locale.ROOT) in teleportedPersonIds
+ }
GeohashPersonItem(
person = person,
- isMe = myHex != null && person.id == myHex,
+ isMe = isMe,
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
- isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id),
- isMyTeleported = person.id == myHex && isTeleported,
- nickname = nickname,
- colorScheme = colorScheme,
+ isTeleported = personIsTeleported,
viewModel = viewModel,
showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1,
onTap = {
- if (person.id != myHex) {
+ if (!isMe) {
viewModel.startGeohashDM(person.id)
onTapPerson()
}
@@ -162,65 +149,71 @@ fun GeohashPeopleList(
)
}
- if (namedPeople.isNotEmpty()) {
- SheetIconSectionHeader(
- iconRes = R.drawable.ic_spec_people,
- title = stringResource(R.string.section_people)
+ if (sections.onLocation.isNotEmpty()) {
+ AboutSectionLabel(text = stringResource(R.string.section_on_location))
+ PeopleCard(
+ people = sections.onLocation,
+ row = { personRow(it) }
)
- PeopleCard(people = namedPeople, row = { personRow(it) })
}
- if (anonPeople.isNotEmpty()) {
- SheetIconSectionHeader(
- iconRes = R.drawable.ic_spec_teleport,
- title = stringResource(R.string.section_anon),
- modifier = Modifier.padding(top = if (namedPeople.isNotEmpty()) 20.dp else 0.dp)
+ if (sections.teleportedIn.isNotEmpty()) {
+ AboutSectionLabel(text = stringResource(R.string.section_teleported_in))
+ PeopleCard(
+ people = sections.teleportedIn,
+ row = { personRow(it) }
)
- PeopleCard(people = anonPeople, capped = true, row = { personRow(it) })
}
}
}
}
-/** Anonymous participants beyond this many are hidden behind the "n more" affordance. */
-internal const val MaxVisibleAnons = 5
+internal data class GeohashPeopleSections(
+ val onLocation: List,
+ val teleportedIn: List
+)
/**
- * Whether this participant never set a nickname.
- *
- * The app labels them `anon` or `anon1234` before the `#abcd` disambiguator, so the base name is
- * what identifies them.
+ * Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities
+ * are omitted, while announced names such as `anon1234` remain ordinary participants. Self is
+ * retained even before a nickname announcement and is always first in the matching section.
*/
-internal fun GeoPerson.isAnonymous(): Boolean {
- val base = splitSuffix(displayName).first
- return base == "anon" || (base.startsWith("anon") && base.drop(4).all { it.isDigit() })
+internal fun sectionGeohashPeople(
+ people: List,
+ myId: String?,
+ selfIsTeleported: Boolean,
+ teleportedIds: Set
+): GeohashPeopleSections {
+ val normalizedMyId = myId?.lowercase(Locale.ROOT)
+ val normalizedTeleportedIds = teleportedIds
+ .mapTo(mutableSetOf()) { it.lowercase(Locale.ROOT) }
+ fun isSelf(person: GeoPerson): Boolean =
+ normalizedMyId != null && person.id.lowercase(Locale.ROOT) == normalizedMyId
+ fun isTeleported(person: GeoPerson): Boolean =
+ if (isSelf(person)) selfIsTeleported
+ else person.id.lowercase(Locale.ROOT) in normalizedTeleportedIds
+
+ val displayedPeople = people.filter { person ->
+ isSelf(person) || !isUnannouncedNickname(person.displayName)
+ }
+ val ordered = displayedPeople.sortedWith(
+ compareByDescending(::isSelf)
+ .thenByDescending { it.lastSeen }
+ )
+ return GeohashPeopleSections(
+ onLocation = ordered.filterNot(::isTeleported),
+ teleportedIn = ordered.filter(::isTeleported)
+ )
}
-/**
- * One grouped card of people.
- *
- * When [capped] the list is trimmed to [MaxVisibleAnons] rows and the remainder is collapsed behind
- * a count. That matters for the anonymous section: a popular geohash can hold dozens of anons, which
- * pushed everyone worth recognising off screen and turned the sheet into a wall of near-identical
- * rows.
- *
- * The capped card is a **fixed height** — [MaxVisibleAnons] rows plus the overflow line, always,
- * regardless of how many anons are currently present beyond the cap. Anons join and leave a busy
- * geohash constantly, and sizing to the live count made the card grow and shrink under the reader
- * every few seconds.
- */
+/** One uncapped card of people. The enclosing sheet owns scrolling. */
@Composable
private fun PeopleCard(
people: List,
- row: @Composable (GeoPerson) -> Unit,
- capped: Boolean = false
+ row: @Composable (GeoPerson) -> Unit
) {
val palette = LocalBitchatPalette.current
- val visible = if (capped) people.take(MaxVisibleAnons) else people
- val hiddenCount = people.size - visible.size
- val isTrimmed = capped && people.size > MaxVisibleAnons
-
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -229,59 +222,10 @@ private fun PeopleCard(
color = palette.surface,
shape = AboutCardShape
) {
- Column {
- Box(
- // Reserve the full capped height up front so the card cannot resize as anons
- // churn. Rows are a fixed height, so this is exact rather than an estimate.
- modifier = if (isTrimmed) {
- Modifier.height(SheetRowHeight * MaxVisibleAnons)
- } else {
- Modifier
- }
- ) {
- AnimatedRowColumn(items = visible, key = { it.id }) { index, person ->
- Column {
- if (index > 0) SheetCardDivider()
- if (isTrimmed && index == visible.lastIndex) {
- // Fade only the final row, so the gradient reads as "the list
- // continues" rather than dimming content still meant to be read.
- Box {
- row(person)
- Box(
- modifier = Modifier
- .matchParentSize()
- .background(
- Brush.verticalGradient(
- listOf(
- palette.surface.copy(alpha = 0f),
- palette.surface.copy(alpha = 0.85f)
- )
- )
- )
- )
- }
- } else {
- row(person)
- }
- }
- }
- }
-
- if (isTrimmed) {
- // Always laid out when trimmed, so the count changing never moves anything.
- // Only the number itself animates.
- AnimatedCountLabel(
- count = hiddenCount,
- text = stringResource(R.string.people_n_more, hiddenCount),
- fontFamily = BitchatFontFamily,
- fontSize = 11.sp,
- color = palette.textTertiary,
- modifier = Modifier.padding(
- start = SheetRowHorizontal,
- end = SheetRowHorizontal,
- bottom = SheetRowVertical
- )
- )
+ AnimatedRowColumn(items = people, key = { it.id }) { index, person ->
+ Column {
+ if (index > 0) SheetCardDivider()
+ row(person)
}
}
}
@@ -293,25 +237,15 @@ private fun GeohashPersonItem(
isMe: Boolean,
hasUnreadDM: Boolean,
isTeleported: Boolean,
- isMyTeleported: Boolean,
- nickname: String,
- colorScheme: ColorScheme,
viewModel: ChatViewModel,
showHashSuffix: Boolean,
onTap: () -> Unit
) {
val palette = LocalBitchatPalette.current
- val (iconName, iconColor) = when {
- isMe && isMyTeleported -> "face.dashed" to palette.accentOrange
- isTeleported -> "face.dashed" to palette.textSecondary
- isMe -> "face.smiling" to palette.accentOrange
- else -> "face.smiling" to palette.textSecondary
- }
- val statusIconRes = when (iconName) {
- "face.dashed" -> R.drawable.ic_spec_teleport
- else -> R.drawable.ic_spec_person
- }
+ val statusIconRes =
+ if (isTeleported) R.drawable.ic_spec_teleport
+ else R.drawable.ic_spec_on_location_person
val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName)
val baseName = truncateNickname(baseNameRaw)
@@ -343,9 +277,13 @@ private fun GeohashPersonItem(
} else {
Icon(
painter = painterResource(statusIconRes),
- contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
+ contentDescription = if (isTeleported) {
+ stringResource(R.string.cd_teleported)
+ } else {
+ stringResource(R.string.section_on_location)
+ },
modifier = Modifier.size(22.dp),
- tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f)
+ tint = baseColor
)
}
}
diff --git a/app/src/main/res/drawable/ic_spec_on_location_person.xml b/app/src/main/res/drawable/ic_spec_on_location_person.xml
new file mode 100644
index 00000000..507d47d8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_on_location_person.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 212f1be2..5ee5c4c1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -317,8 +317,8 @@
People (%1$d)
People
- Anon
- %1$d more\u2026
+ On location
+ Teleported in
Grant location permission
Location permission denied. Enable in settings to use location channels.
diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt
deleted file mode 100644
index c4a76fcf..00000000
--- a/app/src/test/java/com/bitchat/android/ui/GeohashAnonOrderingTest.kt
+++ /dev/null
@@ -1,188 +0,0 @@
-package com.bitchat.android.ui
-
-import java.util.Date
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-/**
- * A busy geohash is mostly anonymous drive-by participants. If they sort by recency alongside
- * everyone else they bury the handful of people worth recognising, so they are pushed to the end of
- * the list and capped.
- */
-class GeohashAnonOrderingTest {
-
- private fun person(name: String, secondsAgo: Long = 0) = GeoPerson(
- id = "id-$name",
- displayName = name,
- lastSeen = Date(1_000_000L - secondsAgo * 1000L)
- )
-
- @Test
- fun `bare anon is anonymous`() {
- assertTrue(person("anon").isAnonymous())
- assertTrue(person("anon#04af").isAnonymous())
- }
-
- @Test
- fun `numbered anon is anonymous`() {
- assertTrue(person("anon7674").isAnonymous())
- assertTrue(person("anon7674#df5b").isAnonymous())
- }
-
- @Test
- fun `a chosen nickname is not anonymous`() {
- assertFalse(person("alice").isAnonymous())
- assertFalse(person("alice#1234").isAnonymous())
- }
-
- @Test
- fun `a nickname that merely starts with anon is not anonymous`() {
- // "anonymous" and "anonracer" are deliberate names, not the generated placeholder.
- assertFalse(person("anonymous").isAnonymous())
- assertFalse(person("anonracer#04af").isAnonymous())
- }
-
- @Test
- fun `named participants sort ahead of anons regardless of recency`() {
- val me = person("me")
- val people = listOf(
- person("anon1", secondsAgo = 0), // most recent of all
- person("zoe", secondsAgo = 500), // stalest named user
- person("anon2", secondsAgo = 10),
- person("alice", secondsAgo = 200)
- )
-
- val ordered = (people + me).sortedWith(
- compareByDescending { it.id == me.id }
- .thenBy { it.isAnonymous() }
- .thenByDescending { it.lastSeen }
- )
-
- assertEquals(
- listOf("me", "alice", "zoe", "anon1", "anon2"),
- ordered.map { it.displayName }
- )
- }
-
- @Test
- fun `recency still orders within each group`() {
- val people = listOf(
- person("bob", secondsAgo = 100),
- person("alice", secondsAgo = 10),
- person("anon9", secondsAgo = 300),
- person("anon1", secondsAgo = 5)
- )
-
- val ordered = people.sortedWith(
- compareBy { it.isAnonymous() }.thenByDescending { it.lastSeen }
- )
-
- assertEquals(
- listOf("alice", "bob", "anon1", "anon9"),
- ordered.map { it.displayName }
- )
- }
-
- @Test
- fun `named participants are never trimmed`() {
- val named = (1..20).map { person("user$it") }
- assertEquals(20, named.filterNot { it.isAnonymous() }.size)
- }
-
- @Test
- fun `anons beyond the cap are counted as hidden`() {
- val people = (1..12).map { person("anon$it") } + person("alice")
-
- val anons = people.filter { it.isAnonymous() }
- val visible = anons.take(MaxVisibleAnons)
-
- assertEquals(MaxVisibleAnons, visible.size)
- assertEquals(12 - MaxVisibleAnons, anons.size - visible.size)
- }
-
- // MARK: - Sectioning
-
- /**
- * Mirrors the two-group split in GeohashPeopleList: peers who announced a nickname, then the
- * anons. Self is never treated as an anon.
- */
- private fun sections(people: List, myId: String?): Triple, List, List> {
- val isSelf: (GeoPerson) -> Boolean = { myId != null && it.id == myId }
- val named = people.filter { isSelf(it) || !it.isAnonymous() }
- val anons = people.filter { !isSelf(it) && it.isAnonymous() }
- return Triple(
- named.map { it.displayName },
- anons.map { it.displayName },
- people.map { it.displayName }
- )
- }
-
- @Test
- fun `anons are grouped out of the people section entirely`() {
- val people = listOf(person("alice"), person("anon1"), person("bob"), person("anon2"))
- val (named, anons, _) = sections(people, myId = null)
-
- assertEquals(listOf("alice", "bob"), named)
- assertEquals(listOf("anon1", "anon2"), anons)
- }
-
- @Test
- fun `self stays in the people section even when unnamed`() {
- // You always want to find yourself where you actually are, not buried in the anon section.
- val me = person("anon")
- val people = listOf(me, person("alice"), person("anon2"))
- val (named, anons, _) = sections(people, myId = me.id)
-
- assertTrue("self must not be grouped as an anon", named.contains("anon"))
- assertFalse(anons.contains("anon"))
- assertEquals(listOf("anon2"), anons)
- }
-
- @Test
- fun `a list of only anons yields no people section`() {
- val people = (1..4).map { person("anon$it") }
- val (named, anons, _) = sections(people, myId = null)
-
- assertTrue(named.isEmpty())
- assertEquals(4, anons.size)
- }
-
- // MARK: - Stable length
-
- @Test
- fun `a trimmed anon list always renders exactly the cap`() {
- // The reserved height is MaxVisibleAnons rows whenever trimmed, so the card cannot resize
- // as anons churn above the cap.
- for (total in listOf(MaxVisibleAnons + 1, MaxVisibleAnons + 7, MaxVisibleAnons + 40)) {
- val anons = (1..total).map { person("anon$it") }
- val visible = anons.take(MaxVisibleAnons)
- assertEquals(
- "row count must not depend on how many anons are present beyond the cap",
- MaxVisibleAnons,
- visible.size
- )
- assertEquals(total - MaxVisibleAnons, anons.size - visible.size)
- }
- }
-
- @Test
- fun `reordering never changes how many rows are rendered`() {
- val anons = (1..9).map { person("anon$it", secondsAgo = it.toLong()) }
- val byRecency = anons.sortedByDescending { it.lastSeen }.take(MaxVisibleAnons)
- val reversed = anons.sortedBy { it.lastSeen }.take(MaxVisibleAnons)
-
- assertEquals(byRecency.size, reversed.size)
- assertEquals(MaxVisibleAnons, byRecency.size)
- }
-
- @Test
- fun `a short anon list is shown in full with nothing hidden`() {
- val people = listOf(person("alice"), person("anon1"), person("anon2"))
-
- val anons = people.filter { it.isAnonymous() }
- assertEquals(2, anons.take(MaxVisibleAnons).size)
- assertEquals(0, anons.size - anons.take(MaxVisibleAnons).size)
- }
-}
diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt
new file mode 100644
index 00000000..3c655f44
--- /dev/null
+++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt
@@ -0,0 +1,83 @@
+package com.bitchat.android.ui
+
+import java.util.Date
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GeohashPresenceGroupingTest {
+
+ private fun person(id: String, name: String = id, lastSeen: Long = 0) = GeoPerson(
+ id = id,
+ displayName = name,
+ lastSeen = Date(lastSeen)
+ )
+
+ @Test
+ fun `heartbeat anon is hidden while announced anon name remains`() {
+ val people = listOf(
+ person("local-heartbeat", "anon"),
+ person("named", "alice"),
+ person("teleported-named", "anon7674"),
+ person("teleported-heartbeat", "anon#04af")
+ )
+
+ val sections = sectionGeohashPeople(
+ people = people,
+ myId = null,
+ selfIsTeleported = false,
+ teleportedIds = setOf("teleported-named", "teleported-heartbeat")
+ )
+
+ assertEquals(listOf("alice"), sections.onLocation.map { it.displayName })
+ assertEquals(listOf("anon7674"), sections.teleportedIn.map { it.displayName })
+ assertEquals(2, sections.onLocation.size + sections.teleportedIn.size)
+ }
+
+ @Test
+ fun `self is first in on-location section when not teleported`() {
+ val sections = sectionGeohashPeople(
+ people = listOf(
+ person("recent", lastSeen = 3_000),
+ person("me", name = "anon", lastSeen = 0),
+ person("older", lastSeen = 1_000)
+ ),
+ myId = "me",
+ selfIsTeleported = false,
+ teleportedIds = emptySet()
+ )
+
+ assertEquals(listOf("me", "recent", "older"), sections.onLocation.map { it.id })
+ assertTrue(sections.teleportedIn.isEmpty())
+ }
+
+ @Test
+ fun `self is first in teleported section when teleported`() {
+ val sections = sectionGeohashPeople(
+ people = listOf(
+ person("remote-teleport", lastSeen = 3_000),
+ person("me", lastSeen = 0),
+ person("local", lastSeen = 2_000)
+ ),
+ myId = "ME",
+ selfIsTeleported = true,
+ teleportedIds = setOf("remote-teleport")
+ )
+
+ assertEquals(listOf("local"), sections.onLocation.map { it.id })
+ assertEquals(listOf("me", "remote-teleport"), sections.teleportedIn.map { it.id })
+ }
+
+ @Test
+ fun `remote teleport matching is case insensitive`() {
+ val sections = sectionGeohashPeople(
+ people = listOf(person("ABCDEF")),
+ myId = null,
+ selfIsTeleported = false,
+ teleportedIds = setOf("abcdef")
+ )
+
+ assertTrue(sections.onLocation.isEmpty())
+ assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id })
+ }
+}
diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt
index 75fb6c01..607aaf1e 100644
--- a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt
+++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt
@@ -25,7 +25,7 @@ class MentionSuggestionsTest {
listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"),
suggestions
)
- assertTrue(suggestions.none(::isUnannouncedMentionNickname))
+ assertTrue(suggestions.none(::isUnannouncedNickname))
}
@Test
@@ -40,11 +40,11 @@ class MentionSuggestionsTest {
@Test
fun `announced names beginning with anon stay mentionable`() {
- assertTrue(isUnannouncedMentionNickname("anon"))
- assertTrue(isUnannouncedMentionNickname("anon#04af"))
- assertFalse(isUnannouncedMentionNickname("anon1234#04af"))
- assertFalse(isUnannouncedMentionNickname("anonymous#04af"))
- assertFalse(isUnannouncedMentionNickname("anonracer"))
+ assertTrue(isUnannouncedNickname("anon"))
+ assertTrue(isUnannouncedNickname("anon#04af"))
+ assertFalse(isUnannouncedNickname("anon1234#04af"))
+ assertFalse(isUnannouncedNickname("anonymous#04af"))
+ assertFalse(isUnannouncedNickname("anonracer"))
}
@Test