mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
grouping of geohash channel list
This commit is contained in:
parent
b1d709fc4a
commit
c1c7704c32
5
app/src/main/assets/design-icons/on-location-person.svg
Normal file
5
app/src/main/assets/design-icons/on-location-person.svg
Normal 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 |
@ -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(
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
13
app/src/main/res/drawable/ic_spec_on_location_person.xml
Normal file
13
app/src/main/res/drawable/ic_spec_on_location_person.xml
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user