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