grouping of geohash channel list

This commit is contained in:
callebtc 2026-07-27 15:46:11 +02:00
parent b1d709fc4a
commit c1c7704c32
10 changed files with 249 additions and 392 deletions

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 15.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 12.5V8.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="8" cy="4.5" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

View File

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

View File

@ -373,6 +373,16 @@ fun splitSuffix(name: String): Pair<String, String> {
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.
*

View File

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

View File

@ -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<GeoPerson> { myHex != null && it.id == myHex }
.thenBy { it.isAnonymous() }
.thenByDescending { it.lastSeen }
)
}
val baseNameCounts = remember(geohashPeople) {
val counts = mutableMapOf<String, Int>()
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<GeoPerson>,
val teleportedIn: List<GeoPerson>
)
/**
* 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<GeoPerson>,
myId: String?,
selfIsTeleported: Boolean,
teleportedIds: Set<String>
): 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<GeoPerson>(::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<GeoPerson>,
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
)
}
}

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M2,15.5H14M8,12.5V8.5M8,0.5A4,4 0,1 1,8,8.5A4,4 0,1 1,8,0.5" />
</vector>

View File

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

View File

@ -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<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)
}
// 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<GeoPerson>, myId: String?): Triple<List<String>, List<String>, List<String>> {
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)
}
}

View File

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

View File

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