Show live geohash activity on the globe picker

The globe picker now shows where people are chatting, in the spirit of
bitmap.lat. While it is open, a monitor subscribes to kind 20000 (last
hour) and kind 20001 (last five minutes) with no geohash filter, on a
worldwide spread of relays plus the few nearest the resting view. Relays
it opened are released when the picker stops.

- Dots are sized by people and colored by chat volume; heartbeat-only
  channels draw as quiet rings, recent chat pulses, and overlapping dots
  merge into clusters that split as you zoom.
- Tapping a dot flies to that geohash and selects it; tapping a cluster
  zooms in and lists its busiest channels. The card shows people and
  messages per hour, a Join button, and the busiest chat inside the cell.
- Clients heartbeat into region, province and city channels under
  separate identities, so people are estimated from the busiest
  precision level instead of summed.
- Signatures are verified for any event that would add a person or a
  message, so forged events cannot inflate counts.
- Observe-only subscriptions bypass the shared deduplicator so the
  sweep never consumes events the open chat still needs.

The picker chrome is redesigned: top bar with a live status chip, a
zoom rail with a whole-globe button, gesture hints that fade after
first touch, and opaque surfaces that no longer show their own shadow.
With no starting geohash it opens on the whole globe.
This commit is contained in:
callebtc 2026-10-27 11:11:15 +01:00
parent eed88190c0
commit b56ed5bb79
43 changed files with 2091 additions and 215 deletions

View File

@ -0,0 +1,213 @@
package com.bitchat.android.nostr
import com.bitchat.android.geohash.Geohash
/** Recent public activity in one exact geohash channel, as seen on the globe picker. */
data class GeohashChannelActivity(
val geohash: String,
val latitude: Double,
val longitude: Double,
/** Distinct people with a heartbeat or message inside the presence window. */
val online: Int,
/** Distinct people who wrote in the channel inside the chat window. */
val chatters: Int,
/** Chat messages inside the chat window. */
val messages: Int,
/** Distinct people across both windows; this is what a marker shows. */
val people: Int,
/** Newest event, epoch seconds. */
val lastActiveAt: Long
)
data class GeohashActivitySnapshot(
/** Channels ordered by [GeohashChannelActivity.people], busiest first. */
val channels: List<GeohashChannelActivity>,
/** Estimated people online worldwide; see [estimateDistinctPeople]. */
val totalOnline: Int,
/** Channels with at least one message inside the chat window. */
val activeChats: Int
) {
private val byGeohash: Map<String, GeohashChannelActivity> by lazy { channels.associateBy { it.geohash } }
fun channel(geohash: String): GeohashChannelActivity? = byGeohash[geohash]
companion object {
val EMPTY = GeohashActivitySnapshot(emptyList(), 0, 0)
}
}
/**
* Estimates distinct people across [channels].
*
* Clients heartbeat into several nested channels at once (region, province and city), each under
* a channel-specific identity, so summing double counts. Each person appears about once per
* precision level, so the busiest level is the honest estimate.
*/
fun estimateDistinctPeople(
channels: Collection<GeohashChannelActivity>,
count: (GeohashChannelActivity) -> Int = { it.people }
): Int {
if (channels.isEmpty()) return 0
val perLevel = IntArray(13)
channels.forEach { perLevel[it.geohash.length.coerceIn(0, 12)] += count(it) }
return perLevel.max()
}
/**
* Folds global kind-20000/20001 traffic into per-channel activity, the way bitmap.lat does:
* chat messages count for an hour, heartbeats for five minutes.
*
* Geohash identities are derived per channel, so a person active in several channels is
* counted once in each. Not thread-safe; the owner serializes access.
*/
class GeohashActivityAggregator(
private val presenceWindowSeconds: Long = PRESENCE_WINDOW_SECONDS,
private val chatWindowSeconds: Long = CHAT_WINDOW_SECONDS,
private val maxChannels: Int = MAX_CHANNELS
) {
private class Message(val pubkey: String, val createdAt: Long)
private class Channel(val latitude: Double, val longitude: Double) {
/** pubkey -> newest event of either kind. */
val lastSeen = HashMap<String, Long>()
/** event id -> message, for chat events only. */
val messages = LinkedHashMap<String, Message>()
var lastActiveAt = 0L
}
private val channels = HashMap<String, Channel>()
/**
* True when [record] would add a person or a message rather than refresh a timestamp.
* The monitor verifies signatures only for these, so forged events cannot inflate counts.
*/
fun wouldGrow(geohash: String, pubkey: String, isChat: Boolean, eventId: String): Boolean {
val channel = channels[normalize(geohash) ?: return false] ?: return true
return if (isChat) eventId !in channel.messages else pubkey.lowercase() !in channel.lastSeen
}
/** Records one event. Returns true when the aggregate changed. */
fun record(
kind: Int,
eventId: String,
pubkey: String,
geohash: String,
createdAt: Long,
content: String,
nowSeconds: Long
): Boolean {
if (kind != NostrKind.EPHEMERAL_EVENT && kind != NostrKind.GEOHASH_PRESENCE) return false
val cell = normalize(geohash) ?: return false
if (createdAt > nowSeconds + MAX_CLOCK_SKEW_SECONDS) return false
val isChat = kind == NostrKind.EPHEMERAL_EVENT && content.isNotBlank()
val window = if (isChat) chatWindowSeconds else presenceWindowSeconds
if (createdAt < nowSeconds - window) return false
val channel = channels[cell] ?: run {
val (lat, lon) = Geohash.decodeToCenter(cell)
Channel(lat, lon).also { channels[cell] = it }
}
val key = pubkey.lowercase()
var changed = false
val previous = channel.lastSeen[key]
if (previous == null || createdAt > previous) {
channel.lastSeen[key] = createdAt
changed = true
}
if (isChat && eventId !in channel.messages) {
channel.messages[eventId] = Message(key, createdAt)
if (channel.messages.size > MAX_MESSAGES_PER_CHANNEL) {
channel.messages.remove(channel.messages.keys.first())
}
changed = true
}
if (createdAt > channel.lastActiveAt) channel.lastActiveAt = createdAt
if (channels.size > maxChannels) evictStalest()
return changed
}
/** Drops events that left their window. Returns true when anything was removed. */
fun prune(nowSeconds: Long): Boolean {
var changed = false
val iterator = channels.values.iterator()
while (iterator.hasNext()) {
val channel = iterator.next()
if (channel.messages.values.removeAll { it.createdAt < nowSeconds - chatWindowSeconds }) changed = true
val chatters = channel.messages.values.mapTo(HashSet()) { it.pubkey }
if (channel.lastSeen.entries.removeAll { (pubkey, seen) ->
seen < nowSeconds - presenceWindowSeconds && pubkey !in chatters
}
) changed = true
if (channel.lastSeen.isEmpty() && channel.messages.isEmpty()) {
iterator.remove()
changed = true
}
}
return changed
}
fun snapshot(nowSeconds: Long): GeohashActivitySnapshot {
val presenceCutoff = nowSeconds - presenceWindowSeconds
val chatCutoff = nowSeconds - chatWindowSeconds
val totalOnline: Int
var activeChats = 0
val result = ArrayList<GeohashChannelActivity>(channels.size)
for ((geohash, channel) in channels) {
val chatters = HashSet<String>()
var messages = 0
for (message in channel.messages.values) {
if (message.createdAt < chatCutoff) continue
chatters.add(message.pubkey)
messages++
}
var online = 0
val people = HashSet<String>(chatters)
for ((pubkey, seen) in channel.lastSeen) {
if (seen >= presenceCutoff) {
online++
people.add(pubkey)
}
}
if (people.isEmpty()) continue
if (messages > 0) activeChats++
result.add(
GeohashChannelActivity(
geohash = geohash,
latitude = channel.latitude,
longitude = channel.longitude,
online = online,
chatters = chatters.size,
messages = messages,
people = people.size,
lastActiveAt = channel.lastActiveAt
)
)
}
result.sortWith(compareByDescending<GeohashChannelActivity> { it.people }.thenByDescending { it.messages })
totalOnline = estimateDistinctPeople(result) { it.online }
return GeohashActivitySnapshot(result, totalOnline, activeChats)
}
fun clear() = channels.clear()
private fun evictStalest() {
val stalest = channels.minByOrNull { it.value.lastActiveAt }?.key ?: return
channels.remove(stalest)
}
private fun normalize(geohash: String): String? {
val cell = geohash.trim().lowercase()
if (cell.isEmpty() || cell.length > MAX_GEOHASH_LENGTH) return null
return cell.takeIf { value -> value.all { it in GEOHASH_ALPHABET } }
}
companion object {
const val PRESENCE_WINDOW_SECONDS = 300L
const val CHAT_WINDOW_SECONDS = 3_600L
private const val MAX_CHANNELS = 600
private const val MAX_MESSAGES_PER_CHANNEL = 500
private const val MAX_CLOCK_SKEW_SECONDS = 300L
private const val MAX_GEOHASH_LENGTH = 12
private const val GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz"
}
}

View File

@ -0,0 +1,209 @@
package com.bitchat.android.nostr
import android.content.Context
import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Watches worldwide geohash chat traffic while the globe picker is open.
*
* Subscribes to kind 20000 (last hour) and kind 20001 (last five minutes) with no `g` filter,
* so the request says nothing about where the user is. Relays are a fixed worldwide spread plus
* the few nearest to wherever the user parks the globe, mirroring bitmap.lat's relay plan.
* Everything is torn down in [stop]; relays this monitor opened are released again.
*/
class GeohashActivityMonitor(context: Context) {
enum class Status { CONNECTING, LIVE, OFFLINE }
private val relayManager = NostrRelayManager.getInstance(context.applicationContext)
private val aggregator = GeohashActivityAggregator()
private val _snapshot = MutableStateFlow(GeohashActivitySnapshot.EMPTY)
val snapshot: StateFlow<GeohashActivitySnapshot> = _snapshot.asStateFlow()
private val _status = MutableStateFlow(Status.CONNECTING)
val status: StateFlow<Status> = _status.asStateFlow()
private var scope: CoroutineScope? = null
private var inbox: Channel<NostrEvent>? = null
private val subscriptionIds = mutableListOf<String>()
private val subscribedRelays = mutableSetOf<String>()
private val openedRelays = mutableSetOf<String>()
private var focusJob: Job? = null
private var lastFocusCell: String? = null
/** Confined to the monitor's single-threaded scope. */
private var dirty = false
fun start() {
if (scope != null) return
// One thread owns the aggregator, so ingest and the publish tick never race.
val newScope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1))
val events = Channel<NostrEvent>(INBOX_CAPACITY, BufferOverflow.DROP_OLDEST)
scope = newScope
inbox = events
_status.value = Status.CONNECTING
newScope.launch {
for (event in events) {
if (ingest(event, System.currentTimeMillis() / 1000)) dirty = true
}
}
newScope.launch { publishLoop() }
newScope.launch {
delay(OFFLINE_GRACE_MS)
if (_status.value == Status.CONNECTING) {
// Connected but nothing to report is still a live, quiet world.
_status.value = if (relayManager.isConnected.value) Status.LIVE else Status.OFFLINE
}
}
subscribe(sweepRelays())
lastFocusCell?.let { cell -> subscribe(focusRelays(cell)) }
}
fun stop() {
val current = scope ?: return
scope = null
focusJob = null
current.cancel()
inbox?.close()
inbox = null
subscriptionIds.forEach(relayManager::unsubscribe)
subscriptionIds.clear()
subscribedRelays.clear()
relayManager.releaseRelays(openedRelays)
openedRelays.clear()
}
/**
* Tells the monitor where the globe is resting. After a short settle it adds the relays
* nearest that region, so quiet local channels show up as well as the busy hubs.
*/
fun focusOn(latitude: Double, longitude: Double) {
val cell = Geohash.encode(latitude, longitude, FOCUS_PRECISION)
if (cell == lastFocusCell) return
lastFocusCell = cell
val current = scope ?: return
focusJob?.cancel()
focusJob = current.launch {
delay(FOCUS_SETTLE_MS)
launch(Dispatchers.Main) { subscribe(focusRelays(cell)) }
}
}
private fun sweepRelays(): List<String> {
val nearestPerAnchor = SWEEP_ANCHORS.map { RelayDirectory.closestRelaysForGeohash(it, RELAYS_PER_ANCHOR) }
val ordered = LinkedHashSet(NostrRelayManager.defaultRelays())
for (rank in 0 until RELAYS_PER_ANCHOR) {
nearestPerAnchor.forEach { relays -> relays.getOrNull(rank)?.let(ordered::add) }
}
return ordered.take(MAX_SWEEP_RELAYS)
}
private fun focusRelays(cell: String): List<String> {
val room = MAX_TOTAL_RELAYS - subscribedRelays.size
if (room <= 0) return emptyList()
return RelayDirectory.closestRelaysForGeohash(cell, RELAYS_PER_FOCUS)
.filter { it !in subscribedRelays }
.take(room)
}
private fun subscribe(relays: List<String>) {
if (scope == null) return
val fresh = relays.filter { it !in subscribedRelays }
if (fresh.isEmpty()) return
val alreadyKnown = relayManager.relays.value.mapTo(HashSet()) { it.url }
openedRelays.addAll(fresh.filter { it !in alreadyKnown })
subscribedRelays.addAll(fresh)
relayManager.connectToRelays(fresh)
val now = System.currentTimeMillis()
val handler: (NostrEvent) -> Unit = { event -> inbox?.trySend(event) }
listOf(
NostrFilter(
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
since = ((now / 1000) - GeohashActivityAggregator.CHAT_WINDOW_SECONDS).toInt(),
limit = REQUEST_LIMIT
),
NostrFilter(
kinds = listOf(NostrKind.GEOHASH_PRESENCE),
since = ((now / 1000) - GeohashActivityAggregator.PRESENCE_WINDOW_SECONDS).toInt(),
limit = REQUEST_LIMIT
)
).forEach { filter ->
subscriptionIds += relayManager.subscribe(
filter = filter,
id = "globe-activity-${UUID.randomUUID()}",
handler = handler,
targetRelayUrls = fresh,
observeOnly = true
)
}
}
private suspend fun CoroutineScope.publishLoop() {
var lastPrune = System.currentTimeMillis()
while (isActive) {
delay(PUBLISH_INTERVAL_MS)
val nowMs = System.currentTimeMillis()
if (nowMs - lastPrune >= PRUNE_INTERVAL_MS) {
if (aggregator.prune(nowMs / 1000)) dirty = true
lastPrune = nowMs
}
if (!dirty) continue
dirty = false
_snapshot.value = aggregator.snapshot(nowMs / 1000)
_status.value = Status.LIVE
}
}
private fun ingest(event: NostrEvent, nowSeconds: Long): Boolean {
val geohash = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.get(1) ?: return false
val isChat = event.kind == NostrKind.EPHEMERAL_EVENT && event.content.isNotBlank()
// Refreshing a known person's timestamp needs no proof; adding anyone or anything does.
if (aggregator.wouldGrow(geohash, event.pubkey, isChat, event.id) && !event.isValidSignature()) {
return false
}
return aggregator.record(
kind = event.kind,
eventId = event.id,
pubkey = event.pubkey,
geohash = geohash,
createdAt = event.createdAt.toLong(),
content = event.content,
nowSeconds = nowSeconds
)
}
private companion object {
/** Synthetic population hubs used only to spread relays around the world. */
val SWEEP_ANCHORS = listOf(
"9q", "dr", "dn", "9g", "d5", "6g", "69", "gc", "u0", "u3", "uc", "sr",
"sx", "st", "s1", "kz", "tt", "te", "w2", "wx", "xn", "r3"
)
const val RELAYS_PER_ANCHOR = 2
const val RELAYS_PER_FOCUS = 3
const val MAX_SWEEP_RELAYS = 24
const val MAX_TOTAL_RELAYS = 36
const val FOCUS_PRECISION = 3
const val FOCUS_SETTLE_MS = 1_200L
const val REQUEST_LIMIT = 500
const val INBOX_CAPACITY = 4_096
const val PUBLISH_INTERVAL_MS = 500L
const val PRUNE_INTERVAL_MS = 15_000L
const val OFFLINE_GRACE_MS = 8_000L
}
}

View File

@ -102,7 +102,13 @@ class NostrRelayManager private constructor() {
val createdAt: Long = System.currentTimeMillis(),
val originGeohash: String? = null,
val owner: String = OWNER_LEGACY,
val liveLocationToken: Long? = null
val liveLocationToken: Long? = null,
/**
* Deliver every matching event straight to [handler] on the socket thread, bypassing the
* shared deduplicator. For observers that must not consume events other subscriptions
* still need (the globe activity sweep overlaps the open chat's subscription).
*/
val observeOnly: Boolean = false
)
// Event deduplication system
@ -174,6 +180,45 @@ class NostrRelayManager private constructor() {
}
}
/**
* Connect to an explicit relay set that carries no location meaning, such as the globe
* picker's worldwide activity sweep. Pair with [releaseRelays].
*/
fun connectToRelays(relayUrls: Collection<String>) {
val urls = relayUrls.filter { it.isNotBlank() }.toSet()
if (urls.isEmpty()) return
// A relay held only for a live-location channel keeps its live gating.
val connectable = urls.filterTo(mutableSetOf()) {
!liveLocationRelayTokens.containsKey(it) || it in nonLiveRelayUrls
}
nonLiveRelayUrls.addAll(connectable)
ensureConnectionsFor(connectable)
}
/**
* Disconnect relays from [connectToRelays] that nothing else needs: not a default relay,
* not routed for a geohash, and not targeted by any remaining subscription.
*/
fun releaseRelays(relayUrls: Collection<String>) {
val routed = buildSet { geohashToRelays.values.forEach { addAll(it) } }
val targeted = buildSet {
activeSubscriptions.values.forEach { info -> info.targetRelayUrls?.let { addAll(it) } }
}
val releasable = relayUrls.toSet() - DEFAULT_RELAYS.toSet() - routed - targeted
if (releasable.isEmpty()) return
releasable.forEach { url ->
reconnectJobs.remove(url)?.cancel()
connections.remove(url)?.close(1000, "Released")
subscriptions.remove(url)
nonLiveRelayUrls.remove(url)
}
synchronized(relaysList) {
relaysList.removeAll { it.url in releasable }
}
updateRelaysList()
updateConnectionStatus()
}
/**
* Get relays mapped to a geohash (empty list if none configured).
*/
@ -484,7 +529,8 @@ class NostrRelayManager private constructor() {
handler: (NostrEvent) -> Unit,
targetRelayUrls: List<String>? = null,
owner: String = OWNER_LEGACY,
liveLocationToken: Long? = null
liveLocationToken: Long? = null,
observeOnly: Boolean = false
): String {
val subscriptionInfo = SubscriptionInfo(
id = id,
@ -492,7 +538,8 @@ class NostrRelayManager private constructor() {
handler = handler,
targetRelayUrls = targetRelayUrls?.toSet(),
owner = owner,
liveLocationToken = liveLocationToken
liveLocationToken = liveLocationToken,
observeOnly = observeOnly
)
runNetworkAction(liveLocationToken) {
@ -939,6 +986,11 @@ class NostrRelayManager private constructor() {
}
}
if (subscriptionInfo.observeOnly) {
messageHandlers[response.subscriptionId]?.invoke(response.event)
return
}
// DEDUPLICATION: Check if we've already processed this event
eventDeduplicator.processEvent(response.event) { event ->
// Call handler for new events only

View File

@ -4,28 +4,63 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LocalFireDepartment
import androidx.compose.material.icons.outlined.Pinch
import androidx.compose.material.icons.outlined._360
import androidx.compose.material.icons.outlined.TouchApp
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.res.ResourcesCompat
@ -33,14 +68,27 @@ import com.bitchat.android.R
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.GeohashActivityMonitor
import com.bitchat.android.nostr.GeohashActivitySnapshot
import com.bitchat.android.nostr.GeohashChannelActivity
import com.bitchat.android.nostr.estimateDistinctPeople
import com.bitchat.android.ui.globe.GlobeActivityColors
import com.bitchat.android.ui.globe.GlobeColors
import com.bitchat.android.ui.globe.GlobeMath
import com.bitchat.android.ui.globe.GlobeMarker
import com.bitchat.android.ui.globe.GlobeState
import com.bitchat.android.ui.globe.GlobeView
import com.bitchat.android.ui.globe.LandData
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.globe.compactCount
import com.bitchat.android.ui.globe.markerColor
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
class GeohashPickerActivity : OrientationAwareActivity() {
@ -50,13 +98,25 @@ class GeohashPickerActivity : OrientationAwareActivity() {
const val EXTRA_RESULT_GEOHASH = "result_geohash"
}
private val activityMonitor by lazy { GeohashActivityMonitor(applicationContext) }
override fun onStart() {
super.onStart()
activityMonitor.start()
}
override fun onStop() {
activityMonitor.stop()
super.onStop()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
var geohashToFocus: String? = null
var initLat = 20.0
var initLon = 0.0
var initLon = 10.0
if (!initialGeohash.isNullOrEmpty()) {
geohashToFocus = initialGeohash
@ -82,7 +142,8 @@ class GeohashPickerActivity : OrientationAwareActivity() {
}
}
val initialPrecision = (geohashToFocus?.length ?: 2).coerceIn(1, 12)
// With nothing to focus, settle on the whole globe so the world's activity is in view.
val initialPrecision = (geohashToFocus?.length ?: 1).coerceIn(1, 12)
val targetLat = initLat
val targetLon = initLon
@ -114,7 +175,23 @@ class GeohashPickerActivity : OrientationAwareActivity() {
value = withContext(Dispatchers.IO) { LandData.loadCities(context) }
}
val activity by activityMonitor.snapshot.collectAsState()
val activityStatus by activityMonitor.status.collectAsState()
// Pull in the relays nearest wherever the user parks the globe.
LaunchedEffect(globeState) {
snapshotFlow {
if (globeState.isInMotion) null
else globeState.centerLat to globeState.centerLon
}.collect { center ->
if (center != null) {
activityMonitor.focusOn(center.first.toDouble(), center.second.toDouble())
}
}
}
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val dark = colorScheme.background.luminance() < 0.5f
val globeColors = remember(colorScheme, dark) {
if (dark) {
@ -149,10 +226,53 @@ class GeohashPickerActivity : OrientationAwareActivity() {
)
}
}
val activityColors = remember(colorScheme, palette) {
GlobeActivityColors(
quiet = colorScheme.secondary,
active = colorScheme.primary,
hot = palette.accentOrange,
text = Color.White,
quietText = colorScheme.onSurface,
selection = colorScheme.onSurface
)
}
val labelTypeface = remember { ResourcesCompat.getFont(context, R.font.geist_mono_medium) }
val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) }
// Geohashes of a tapped cluster, shown as a pick list until one is chosen.
var cluster by remember { mutableStateOf<List<String>?>(null) }
var hasInteracted by remember { mutableStateOf(false) }
LaunchedEffect(globeState) {
snapshotFlow { globeState.isInteracting }.filter { it }.first()
hasInteracted = true
}
fun focusChannel(geohash: String) {
val (lat, lon) = Geohash.decodeToCenter(geohash)
val zoom = GlobeMath.zoomForPrecision(geohash.length, globeState.baseRadiusPx, globeState.screenMinPx)
globeState.animateTo(lat, lon, zoom, geohash.length, durationMs = 650)
}
fun onMarkerTap(marker: GlobeMarker) {
hasInteracted = true
if (!marker.isCluster) {
cluster = null
focusChannel(marker.primary.geohash)
return
}
cluster = marker.channels.map { it.geohash }
val (lat, lon) = marker.centroid
val zoom = (globeState.zoom * CLUSTER_ZOOM_STEP).coerceAtMost(GlobeMath.MAX_ZOOM)
globeState.animateTo(lat, lon, zoom, null, durationMs = 600)
}
fun join(geohash: String) {
if (geohash.isEmpty()) return
setResult(Activity.RESULT_OK, Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, geohash) })
finish()
}
Box(
Modifier
.fillMaxSize()
@ -167,123 +287,95 @@ class GeohashPickerActivity : OrientationAwareActivity() {
cities = cities,
labelTypeface = labelTypeface,
labelTypefaceBold = labelTypefaceBold,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
activity = activity.channels,
activityColors = activityColors,
onMarkerTap = ::onMarkerTap,
onBackgroundTap = { cluster = null }
)
}
// Floating info pill
Surface(
// Scrims keep the floating chrome legible over bright land.
EdgeScrim(Alignment.TopCenter, colorScheme.background, 132.dp)
EdgeScrim(Alignment.BottomCenter, colorScheme.background, 220.dp)
PickerTopBar(
status = activityStatus,
snapshot = activity,
onClose = { finish() },
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 20.dp)
.fillMaxWidth(0.8f),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
.padding(horizontal = 16.dp, vertical = 8.dp)
)
ZoomRail(
precision = globeState.precision,
onZoomIn = { globeState.animatePrecision(globeState.precision + 1) },
onZoomOut = { globeState.animatePrecision(globeState.precision - 1) },
onWholeGlobe = {
cluster = null
globeState.animateTo(
globeState.centerLat.toDouble(),
globeState.centerLon.toDouble(),
GlobeMath.MIN_ZOOM,
1,
durationMs = 700
)
},
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 12.dp)
)
// Floating bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.widthIn(max = 520.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Geohash label (monospace, app style)
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = if (globeState.selectedGeohash.isNotEmpty()) "#${globeState.selectedGeohash}" else "select location",
fontSize = BASE_FONT_SIZE.sp,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
if (globeState.selectedGeohash.isNotEmpty()) {
Text(
text = "${levelForLength(globeState.precision).displayName} • ${coverageString(globeState.precision)}",
fontSize = (BASE_FONT_SIZE - 4).sp,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
GestureHints(
visible = !hasInteracted,
showTapHint = activity.channels.isNotEmpty()
)
PickerCard {
AnimatedContent(
targetState = cluster,
transitionSpec = {
fadeIn(tween(BitchatMotion.EMPHASIZED_MS)) togetherWith
fadeOut(tween(BitchatMotion.QUICK_MS))
},
label = "pickerCard"
) { clusterGeohashes ->
if (clusterGeohashes != null) {
ClusterList(
geohashes = clusterGeohashes,
snapshot = activity,
colors = activityColors,
onPick = { geohash ->
cluster = null
focusChannel(geohash)
},
onDismiss = { cluster = null }
)
} else {
SelectionSummary(
geohash = globeState.selectedGeohash,
level = levelForLength(globeState.precision),
coverage = coverageString(globeState.precision),
channel = activity.channel(globeState.selectedGeohash),
busiestInside = busiestInside(activity, globeState.selectedGeohash),
colors = activityColors,
onJoin = { join(globeState.selectedGeohash) },
onFocus = ::focusChannel
)
}
}
}
// Button row
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Decrease precision
Button(
onClick = { globeState.animatePrecision(globeState.precision - 1) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
}
// Increase precision
Button(
onClick = { globeState.animatePrecision(globeState.precision + 1) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
}
// Select button
Button(
onClick = {
val gh = globeState.selectedGeohash
if (gh.isNotEmpty()) {
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
setResult(Activity.RESULT_OK, result)
finish()
}
},
enabled = globeState.selectedGeohash.isNotEmpty(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.select),
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = BitchatFontFamily
)
}
}
}
}
}
@ -322,3 +414,478 @@ class GeohashPickerActivity : OrientationAwareActivity() {
}
}
}
private const val CLUSTER_ZOOM_STEP = 2.6f
private val ChromeShape = RoundedCornerShape(22.dp)
private val ControlSize = 44.dp
@Composable
private fun BoxScope.EdgeScrim(alignment: Alignment, color: Color, height: Dp) {
val top = alignment == Alignment.TopCenter
Box(
Modifier
.align(alignment)
.fillMaxWidth()
.height(height)
.background(
Brush.verticalGradient(
if (top) listOf(color.copy(alpha = 0.72f), Color.Transparent)
else listOf(Color.Transparent, color.copy(alpha = 0.78f))
)
)
)
}
/**
* Floating surface shared by every piece of picker chrome. Opaque on purpose: a translucent
* surface lets its own shadow show through as a muddy inner box.
*/
@Composable
private fun ChromeSurface(
modifier: Modifier = Modifier,
shape: androidx.compose.ui.graphics.Shape = ChromeShape,
content: @Composable () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
Surface(
modifier = modifier,
shape = shape,
color = colorScheme.surface,
border = BorderStroke(1.dp, colorScheme.outline.copy(alpha = 0.55f)),
shadowElevation = 10.dp,
content = content
)
}
@Composable
private fun PickerTopBar(
status: GeohashActivityMonitor.Status,
snapshot: GeohashActivitySnapshot,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
ChromeSurface(shape = CircleShape) {
IconButton(onClick = onClose, modifier = Modifier.size(ControlSize)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(Modifier.weight(1f))
LiveActivityChip(status = status, snapshot = snapshot)
}
}
/** "● LIVE 💬 20 👤 202", the at-a-glance pulse of the whole map. */
@Composable
private fun LiveActivityChip(
status: GeohashActivityMonitor.Status,
snapshot: GeohashActivitySnapshot
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val live = status == GeohashActivityMonitor.Status.LIVE
val dotColor = when (status) {
GeohashActivityMonitor.Status.LIVE -> colorScheme.primary
GeohashActivityMonitor.Status.CONNECTING -> palette.accentOrange
GeohashActivityMonitor.Status.OFFLINE -> palette.textTertiary
}
val breathe by rememberInfiniteTransition(label = "liveDot").animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse),
label = "liveDotAlpha"
)
val description = stringResource(R.string.cd_globe_activity, snapshot.activeChats, snapshot.totalOnline)
ChromeSurface(
shape = CircleShape,
modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = description }
) {
Row(
modifier = Modifier
.height(ControlSize)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
Modifier
.size(8.dp)
.alpha(if (status == GeohashActivityMonitor.Status.OFFLINE) 1f else breathe)
.background(dotColor, CircleShape)
)
Text(
text = stringResource(
when (status) {
GeohashActivityMonitor.Status.LIVE -> R.string.globe_live
GeohashActivityMonitor.Status.CONNECTING -> R.string.globe_connecting
GeohashActivityMonitor.Status.OFFLINE -> R.string.globe_offline
}
).uppercase(),
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
letterSpacing = 1.sp,
color = if (live) colorScheme.primary else colorScheme.onSurfaceVariant
)
if (live || snapshot.channels.isNotEmpty()) {
StatValue(Icons.Outlined.ChatBubbleOutline, compactCount(snapshot.activeChats))
StatValue(Icons.Filled.Person, compactCount(snapshot.totalOnline))
}
}
}
}
@Composable
private fun StatValue(
icon: ImageVector,
value: String,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
textColor: Color = MaterialTheme.colorScheme.onSurface
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(14.dp))
Text(
text = value,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
color = textColor
)
}
}
@Composable
private fun ZoomRail(
precision: Int,
onZoomIn: () -> Unit,
onZoomOut: () -> Unit,
onWholeGlobe: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
ChromeSurface(shape = CircleShape) {
IconButton(onClick = onWholeGlobe, modifier = Modifier.size(ControlSize)) {
Icon(
Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_show_whole_globe),
tint = colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
}
}
ChromeSurface(shape = RoundedCornerShape(ControlSize / 2)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
IconButton(
onClick = onZoomIn,
enabled = precision < GlobeMath.MAX_PRECISION,
modifier = Modifier.size(ControlSize)
) {
Icon(
Icons.Filled.Add,
contentDescription = stringResource(R.string.cd_increase_precision),
tint = colorScheme.onSurface
)
}
HorizontalDivider(
modifier = Modifier.width(20.dp),
color = colorScheme.outline.copy(alpha = 0.6f)
)
// Current geohash length: the "zoom level" the + and − buttons step through.
Text(
text = precision.toString(),
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp)
)
HorizontalDivider(
modifier = Modifier.width(20.dp),
color = colorScheme.outline.copy(alpha = 0.6f)
)
IconButton(
onClick = onZoomOut,
enabled = precision > 1,
modifier = Modifier.size(ControlSize)
) {
Icon(
Icons.Filled.Remove,
contentDescription = stringResource(R.string.cd_decrease_precision),
tint = colorScheme.onSurface
)
}
}
}
}
}
/** First-run gesture coaching; fades for good once the globe is touched. */
@Composable
private fun GestureHints(visible: Boolean, showTapHint: Boolean) {
var timedOut by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(GESTURE_HINT_TIMEOUT_MS)
timedOut = true
}
AnimatedVisibility(
visible = visible && !timedOut,
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)) +
slideInVertically(tween(BitchatMotion.EMPHASIZED_MS)) { it / 2 },
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)) +
slideOutVertically(tween(BitchatMotion.EMPHASIZED_MS)) { it / 2 }
) {
ChromeSurface(shape = CircleShape) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
GestureHint(Icons.Outlined._360, stringResource(R.string.globe_hint_drag))
GestureHint(Icons.Outlined.Pinch, stringResource(R.string.globe_hint_pinch))
if (showTapHint) {
GestureHint(Icons.Outlined.TouchApp, stringResource(R.string.globe_hint_tap))
}
}
}
}
}
private const val GESTURE_HINT_TIMEOUT_MS = 12_000L
@Composable
private fun GestureHint(icon: ImageVector, label: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(15.dp))
Text(
text = label,
fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
@Composable
private fun PickerCard(content: @Composable () -> Unit) {
ChromeSurface(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp)) {
Box(Modifier.padding(16.dp)) { content() }
}
}
@Composable
private fun SelectionSummary(
geohash: String,
level: GeohashChannelLevel,
coverage: String,
channel: GeohashChannelActivity?,
busiestInside: GeohashChannelActivity?,
colors: GlobeActivityColors,
onJoin: () -> Unit,
onFocus: (String) -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
text = if (geohash.isNotEmpty()) "#$geohash" else "…",
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
color = colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${level.displayName.lowercase()} · $coverage",
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = colorScheme.onSurfaceVariant,
maxLines = 1
)
if (channel != null) {
ChannelActivityLine(channel, colors, Modifier.padding(top = 4.dp))
} else {
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(Modifier.size(6.dp).background(palette.textTertiary.copy(alpha = 0.6f), CircleShape))
Text(
text = stringResource(R.string.globe_no_activity),
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = palette.textTertiary
)
}
}
}
Spacer(Modifier.width(12.dp))
Button(
onClick = onJoin,
enabled = geohash.isNotEmpty(),
shape = CircleShape,
contentPadding = PaddingValues(start = 20.dp, end = 16.dp),
modifier = Modifier.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.primary,
contentColor = colorScheme.onPrimary
)
) {
Text(
text = stringResource(R.string.join),
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp
)
Spacer(Modifier.width(6.dp))
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
if (busiestInside != null) {
HorizontalDivider(
modifier = Modifier.padding(top = 14.dp, bottom = 4.dp),
color = colorScheme.outline.copy(alpha = 0.5f)
)
ChannelRow(
channel = busiestInside,
colors = colors,
leadingIcon = Icons.Outlined.LocalFireDepartment,
onClick = { onFocus(busiestInside.geohash) }
)
}
}
}
/**
* The liveliest conversation nested inside [geohash], offered as a one-tap jump when the
* selection itself is quieter. Chats rank above heartbeats.
*/
private fun busiestInside(snapshot: GeohashActivitySnapshot, geohash: String): GeohashChannelActivity? {
if (geohash.isEmpty()) return null
val selectedMessages = snapshot.channel(geohash)?.messages ?: 0
return snapshot.channels
.filter { it.geohash.length > geohash.length && it.geohash.startsWith(geohash) && it.messages > selectedMessages }
.maxWithOrNull(compareBy<GeohashChannelActivity> { it.messages }.thenBy { it.people })
}
/** Heat dot, people and messages/hour — the same encoding as the marker on the globe. */
@Composable
private fun ChannelActivityLine(
channel: GeohashChannelActivity,
colors: GlobeActivityColors,
modifier: Modifier = Modifier
) {
val description = stringResource(R.string.cd_channel_activity, channel.people, channel.messages)
Row(
modifier = modifier.semantics(mergeDescendants = true) { contentDescription = description },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(Modifier.size(8.dp).background(markerColor(channel.messages, colors), CircleShape))
StatValue(Icons.Filled.Person, compactCount(channel.people))
if (channel.messages > 0) {
StatValue(Icons.Outlined.ChatBubbleOutline, "${compactCount(channel.messages)}/h")
}
}
}
@Composable
private fun ClusterList(
geohashes: List<String>,
snapshot: GeohashActivitySnapshot,
colors: GlobeActivityColors,
onPick: (String) -> Unit,
onDismiss: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val channels = remember(geohashes, snapshot) {
geohashes.mapNotNull(snapshot::channel).sortedByDescending { it.people }
}
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
StatValue(
Icons.Outlined.Layers,
compactCount(channels.size),
tint = colorScheme.primary
)
Spacer(Modifier.width(14.dp))
StatValue(Icons.Filled.Person, compactCount(estimateDistinctPeople(channels)))
Spacer(Modifier.weight(1f))
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.cd_close),
tint = colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
Spacer(Modifier.height(8.dp))
channels.take(MAX_CLUSTER_ROWS).forEach { channel ->
ChannelRow(channel, colors, onClick = { onPick(channel.geohash) })
}
}
}
private const val MAX_CLUSTER_ROWS = 4
@Composable
private fun ChannelRow(
channel: GeohashChannelActivity,
colors: GlobeActivityColors,
leadingIcon: ImageVector? = null,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (leadingIcon != null) {
Icon(leadingIcon, contentDescription = null, tint = markerColor(channel.messages, colors), modifier = Modifier.size(16.dp))
} else {
Box(Modifier.size(10.dp).background(markerColor(channel.messages, colors), CircleShape))
}
Text(
text = "#${channel.geohash}",
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
color = colorScheme.onSurface,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
StatValue(Icons.Filled.Person, compactCount(channel.people))
if (channel.messages > 0) {
StatValue(Icons.Outlined.ChatBubbleOutline, "${compactCount(channel.messages)}/h")
}
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
}
}

View File

@ -0,0 +1,308 @@
package com.bitchat.android.ui.globe
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import com.bitchat.android.nostr.GeohashChannelActivity
import com.bitchat.android.nostr.estimateDistinctPeople
import java.util.Locale
import kotlin.math.ln
import kotlin.math.sqrt
/** Marker colors, derived from the app theme by the caller. */
data class GlobeActivityColors(
/** Heartbeats only: people have the app open here but nobody wrote this hour. */
val quiet: Color,
/** Some chat this hour. */
val active: Color,
/** Busy chat; markers blend toward this as messages climb. */
val hot: Color,
/** Count on a solid chat marker. */
val text: Color,
/** Count on a translucent heartbeat-only marker. */
val quietText: Color,
val selection: Color
)
/** One marker on screen: a single channel, or overlapping channels merged into a cluster. */
class GlobeMarker internal constructor(
val x: Float,
val y: Float,
val radius: Float,
/** Busiest first. */
val channels: List<GeohashChannelActivity>,
/** Estimated distinct people; see [estimateDistinctPeople]. */
val people: Int,
val messages: Int,
/** Newest activity across [channels], epoch seconds. */
val lastActiveAt: Long
) {
val isCluster: Boolean get() = channels.size > 1
/** Nobody wrote this hour: only heartbeats. Drawn small and quiet. */
val isAmbient: Boolean get() = messages == 0
val primary: GeohashChannelActivity get() = channels.first()
/** People-weighted center of the merged channels, for zooming into a cluster. */
val centroid: Pair<Double, Double>
get() {
var vx = 0.0
var vy = 0.0
var vz = 0.0
for (channel in channels) {
val lat = Math.toRadians(channel.latitude)
val lon = Math.toRadians(channel.longitude)
val w = channel.people.toDouble()
vx += w * kotlin.math.cos(lat) * kotlin.math.cos(lon)
vy += w * kotlin.math.cos(lat) * kotlin.math.sin(lon)
vz += w * kotlin.math.sin(lat)
}
val hyp = sqrt(vx * vx + vy * vy)
return Math.toDegrees(kotlin.math.atan2(vz, hyp)) to Math.toDegrees(kotlin.math.atan2(vy, vx))
}
}
/** Markers from the most recent frame; read by the tap handler. */
class GlobeMarkerLayout {
@Volatile
var markers: List<GlobeMarker> = emptyList()
internal set
/** The marker under [position], preferring the one whose edge is closest. */
fun hitTest(position: Offset, slopPx: Float): GlobeMarker? {
var best: GlobeMarker? = null
var bestDistance = Float.MAX_VALUE
for (marker in markers) {
val dx = marker.x - position.x
val dy = marker.y - position.y
val edge = sqrt(dx * dx + dy * dy) - marker.radius
if (edge <= slopPx && edge < bestDistance) {
best = marker
bestDistance = edge
}
}
return best
}
}
/**
* Radius in dp. Area tracks headcount (square root), and chat markers outrank heartbeat-only
* ones so conversations stand out from people who merely have the app open.
*/
internal fun markerRadiusDp(people: Int, chatting: Boolean, cluster: Boolean): Float {
val root = sqrt(people.coerceAtLeast(1).toFloat())
return if (chatting) {
val base = CHAT_MIN_RADIUS_DP + CHAT_RADIUS_PER_SQRT_PERSON_DP * root
(if (cluster) base.coerceAtLeast(CLUSTER_MIN_RADIUS_DP) else base).coerceAtMost(CHAT_MAX_RADIUS_DP)
} else {
(AMBIENT_MIN_RADIUS_DP + AMBIENT_RADIUS_PER_SQRT_PERSON_DP * root).coerceAtMost(AMBIENT_MAX_RADIUS_DP)
}
}
/** 0 when nobody wrote, rising toward 1 around a message a minute. */
internal fun markerHeat(messages: Int): Float =
if (messages <= 0) 0f else (ln(1f + messages) / ln(1f + HOT_MESSAGES_PER_HOUR)).coerceIn(0f, 1f)
/** Marker fill: quiet for heartbeats, then green through yellow to hot as chat picks up. */
fun markerColor(messages: Int, colors: GlobeActivityColors): Color =
if (messages <= 0) colors.quiet else lerpHue(colors.active, colors.hot, markerHeat(messages))
/** Interpolates around the hue wheel, so green to orange passes yellow instead of mud. */
internal fun lerpHue(from: Color, to: Color, t: Float): Color {
val a = FloatArray(3).also { android.graphics.Color.colorToHSV(from.toArgb(), it) }
val b = FloatArray(3).also { android.graphics.Color.colorToHSV(to.toArgb(), it) }
var dh = b[0] - a[0]
if (dh > 180f) dh -= 360f
if (dh < -180f) dh += 360f
val hsv = floatArrayOf(
(a[0] + dh * t + 360f) % 360f,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t
)
return Color(android.graphics.Color.HSVToColor(hsv))
}
/** "7", "842", "1.2k", "12k". */
internal fun compactCount(count: Int): String = when {
count < 1_000 -> count.toString()
count < 10_000 -> String.format(Locale.US, "%.1fk", count / 1_000f).replace(".0k", "k")
else -> "${count / 1_000}k"
}
/**
* Projects channels onto the visible hemisphere and greedily merges markers that would overlap,
* busiest first, so a crowded region reads as one labelled cluster until the user zooms in.
*/
internal fun layoutGlobeMarkers(
channels: List<GeohashChannelActivity>,
projector: GlobeMath.PreparedProjector,
cx: Float,
cy: Float,
r: Float,
width: Float,
height: Float,
density: Float
): List<GlobeMarker> {
class Group(val x: Float, val y: Float, first: GeohashChannelActivity, var radius: Float) {
val members = mutableListOf(first)
var messages = first.messages
}
val terms = FloatArray(4)
val projection = FloatArray(3)
val margin = MARKER_MAX_RADIUS_DP * density
val groups = ArrayList<Group>()
for (channel in channels) {
val lat = Math.toRadians(channel.latitude)
val lon = Math.toRadians(channel.longitude)
terms[0] = kotlin.math.sin(lat).toFloat()
terms[1] = kotlin.math.cos(lat).toFloat()
terms[2] = kotlin.math.sin(lon).toFloat()
terms[3] = kotlin.math.cos(lon).toFloat()
projector.project(terms, 0, projection, 0)
if (projection[2] < MIN_VISIBLE_COS) continue
val x = cx + projection[0] * r
val y = cy + projection[1] * r
if (x < -margin || x > width + margin || y < -margin || y > height + margin) continue
val radius = markerRadiusDp(channel.people, channel.messages > 0, cluster = false) * density
val host = groups.firstOrNull { group ->
val dx = group.x - x
val dy = group.y - y
val reach = (group.radius + radius) * MERGE_OVERLAP + MERGE_GAP_DP * density
dx * dx + dy * dy < reach * reach
}
if (host == null) {
groups.add(Group(x, y, channel, radius))
} else {
host.members.add(channel)
host.messages += channel.messages
host.radius = markerRadiusDp(
estimateDistinctPeople(host.members),
chatting = host.messages > 0,
cluster = true
) * density
}
}
return groups.map { group ->
GlobeMarker(
x = group.x,
y = group.y,
radius = group.radius,
channels = group.members,
people = estimateDistinctPeople(group.members),
messages = group.messages,
lastActiveAt = group.members.maxOf { it.lastActiveAt }
)
}
}
internal fun DrawScope.drawGlobeMarkers(
markers: List<GlobeMarker>,
colors: GlobeActivityColors,
selectedGeohash: String,
pulsePhase: Float,
nowSeconds: Long,
textPaint: Paint,
typeface: Typeface?,
density: Float
) {
val canvas = drawContext.canvas.nativeCanvas
textPaint.typeface = typeface
textPaint.textAlign = Paint.Align.CENTER
// Heartbeat-only markers first so conversations always sit on top.
for (marker in markers.sortedBy { it.messages * 1_000 + it.people }) {
val center = Offset(marker.x, marker.y)
val color = markerColor(marker.messages, colors)
val radius = marker.radius
val selected = marker.channels.any { it.geohash == selectedGeohash }
if (marker.isAmbient) {
drawCircle(
brush = Brush.radialGradient(
0f to color.copy(alpha = 0.22f),
1f to Color.Transparent,
center = center,
radius = radius * AMBIENT_GLOW_SCALE
),
radius = radius * AMBIENT_GLOW_SCALE,
center = center
)
drawCircle(color.copy(alpha = 0.30f), radius = radius, center = center)
drawCircle(color.copy(alpha = 0.85f), radius = radius, center = center, style = Stroke(1.2f * density))
} else {
drawCircle(
brush = Brush.radialGradient(
0f to color.copy(alpha = 0.45f),
0.45f to color.copy(alpha = 0.16f),
1f to Color.Transparent,
center = center,
radius = radius * GLOW_SCALE
),
radius = radius * GLOW_SCALE,
center = center
)
if (nowSeconds - marker.lastActiveAt < MARKER_RECENT_SECONDS) {
drawCircle(
color = color.copy(alpha = 0.6f * (1f - pulsePhase)),
radius = radius * (1f + 1.1f * pulsePhase),
center = center,
style = Stroke(width = 1.6f * density)
)
}
if (marker.isCluster) {
drawCircle(color.copy(alpha = 0.28f), radius = radius + 3f * density, center = center)
}
drawCircle(color, radius = radius, center = center)
drawCircle(
color = Color.White.copy(alpha = 0.5f),
radius = radius - 0.5f * density,
center = center,
style = Stroke(width = 1f * density)
)
}
if (selected) {
drawCircle(
color = colors.selection,
radius = radius + 4.5f * density,
center = center,
style = Stroke(width = 2f * density)
)
}
if (radius >= LABEL_MIN_RADIUS_DP * density) {
val label = compactCount(marker.people)
textPaint.color = if (marker.isAmbient) colors.quietText.toArgb() else colors.text.toArgb()
textPaint.textSize = (radius * 0.95f).coerceIn(9f * density, 12.5f * density) *
if (label.length >= 3) 0.84f else 1f
val baseline = marker.y - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(label, marker.x, baseline, textPaint)
}
}
}
/** Chat markers active this recently pulse. */
internal const val MARKER_RECENT_SECONDS = 180L
private const val CHAT_MIN_RADIUS_DP = 5f
private const val CHAT_RADIUS_PER_SQRT_PERSON_DP = 2.4f
private const val CHAT_MAX_RADIUS_DP = 17f
private const val CLUSTER_MIN_RADIUS_DP = 9f
private const val AMBIENT_MIN_RADIUS_DP = 2.5f
private const val AMBIENT_RADIUS_PER_SQRT_PERSON_DP = 1.3f
private const val AMBIENT_MAX_RADIUS_DP = 9.5f
private const val LABEL_MIN_RADIUS_DP = 8.5f
private const val HOT_MESSAGES_PER_HOUR = 60f
private const val MIN_VISIBLE_COS = 0.06f
private const val MERGE_OVERLAP = 0.85f
private const val MERGE_GAP_DP = 1.5f
private const val GLOW_SCALE = 2.3f
private const val AMBIENT_GLOW_SCALE = 1.8f
private const val MARKER_MAX_RADIUS_DP = CHAT_MAX_RADIUS_DP

View File

@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
@ -42,6 +43,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.nostr.GeohashChannelActivity
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlin.math.ceil
@ -152,7 +154,11 @@ fun GlobeView(
cities: List<LandData.City>,
labelTypeface: Typeface?,
labelTypefaceBold: Typeface?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
activity: List<GeohashChannelActivity> = emptyList(),
activityColors: GlobeActivityColors? = null,
onMarkerTap: ((GlobeMarker) -> Unit)? = null,
onBackgroundTap: (() -> Unit)? = null
) {
val view = LocalView.current
val density = LocalDensity.current
@ -184,6 +190,17 @@ fun GlobeView(
}
}
val markerLayout = remember { GlobeMarkerLayout() }
val markerTextPaint = remember { Paint(Paint.ANTI_ALIAS_FLAG) }
val markerPulse by rememberInfiniteTransition(label = "markers").animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1800), RepeatMode.Restart),
label = "markerPulse"
)
val currentOnMarkerTap by rememberUpdatedState(onMarkerTap)
val currentOnBackgroundTap by rememberUpdatedState(onBackgroundTap)
val pulse by rememberInfiniteTransition(label = "crosshair").animateFloat(
initialValue = 0.45f,
targetValue = 1f,
@ -313,7 +330,16 @@ fun GlobeView(
upTime - downTime < 400 &&
moved.getDistance() < viewConfiguration.touchSlop
if (isTap) {
val tappedMarker = if (isTap) {
currentOnMarkerTap?.let {
markerLayout.hitTest(downPos, MARKER_TAP_SLOP_DP * density.density)
}
} else null
if (tappedMarker != null) {
lastTapTime = 0L
currentOnMarkerTap?.invoke(tappedMarker)
} else if (isTap) {
currentOnBackgroundTap?.invoke()
val cx = size.width / 2f
val cy = size.height / 2f
val r = state.globeRadiusPx
@ -490,9 +516,51 @@ fun GlobeView(
drawLine(crossColor, Offset(cx, cy + gap), Offset(cx, cy + gap + len), strokeW)
drawCircle(crossColor, radius = 1.8f * density.density, center = Offset(cx, cy))
}
// Activity markers get their own layer, above the crosshair so a centered marker's
// count stays readable; their pulse never redraws the globe geometry.
if (activityColors != null) {
Canvas(modifier = Modifier.matchParentSize()) {
val r = state.globeRadiusPx
if (state.baseRadiusPx <= 0f || activity.isEmpty()) {
markerLayout.markers = emptyList()
return@Canvas
}
val markers = layoutGlobeMarkers(
channels = activity,
projector = GlobeMath.PreparedProjector(
state.centerLat.toDouble(),
state.centerLon.toDouble()
),
cx = size.width / 2f,
cy = size.height / 2f,
r = r,
width = size.width,
height = size.height,
density = density.density
)
markerLayout.markers = markers
val nowSeconds = System.currentTimeMillis() / 1000
// Only recently active markers pulse; reading the clock just for them keeps a
// quiet globe from redrawing every frame.
val anyRecent = markers.any {
it.messages > 0 && nowSeconds - it.lastActiveAt < MARKER_RECENT_SECONDS
}
drawGlobeMarkers(
markers = markers,
colors = activityColors,
selectedGeohash = state.selectedGeohash,
pulsePhase = if (anyRecent) markerPulse else 0f,
nowSeconds = nowSeconds,
textPaint = markerTextPaint,
typeface = labelTypefaceBold ?: labelTypeface,
density = density.density
)
}
}
}
}
private const val MARKER_TAP_SLOP_DP = 12f
private const val FRAME_SAMPLE_COUNT = 8
private const val MAX_SAMPLED_FRAME_MILLIS = 50f
private const val SMOOTH_FRAME_MILLIS = 18.5f

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">مفضل (غير متصل)</string>
<string name="cd_decrease_precision">خفض الدقة</string>
<string name="cd_increase_precision">رفع الدقة</string>
<string name="cd_select_geohash">اختر geohash</string>
<string name="cd_scroll_to_bottom">التمرير إلى الأسفل</string>
<string name="cd_file">ملف</string>
<string name="cd_image">صورة</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat لا يتتبع موقعك</string>
<string name="nobody_around">لا أحد هنا…</string>
<string name="you_suffix"> (أنت)</string>
<string name="pan_zoom_instruction">حرّك وقرّب لاختيار geohash</string>
<string name="select">اختيار</string>
<string name="type_a_message_placeholder">اكتب رسالة…</string>
<string name="mention">إشارة</string>
<string name="version_prefix">v%1$s</string>
@ -395,4 +392,14 @@
<string name="about_select_language">اختر اللغة</string>
<string name="about_spam_filter_title">فلتر الرسائل المزعجة</string>
<string name="about_spam_filter_desc">إخفاء الرسائل العامة من الأشخاص الذين يرسلون بسرعة مفرطة أو يكررون رسائلهم أو يغرقون الدردشة بالروابط.</string>
<string name="globe_live">مباشر</string>
<string name="globe_connecting">جارٍ الاتصال</string>
<string name="globe_offline">غير متصل</string>
<string name="globe_hint_drag">تدوير</string>
<string name="globe_hint_pinch">تكبير</string>
<string name="globe_hint_tap">اضغط على نقطة</string>
<string name="globe_no_activity">لا يوجد نشاط حديث</string>
<string name="cd_show_whole_globe">عرض الكرة الأرضية كاملة</string>
<string name="cd_globe_activity">%1$d محادثات نشطة، %2$d أشخاص متصلون</string>
<string name="cd_channel_activity">%1$d أشخاص، %2$d رسائل في الساعة الأخيرة</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">প্রিয় (অফলাইন)</string>
<string name="cd_decrease_precision">নির্ভুলতা কমান</string>
<string name="cd_increase_precision">নির্ভুলতা বাড়ান</string>
<string name="cd_select_geohash">জিওহ্যাশ নির্বাচন করুন</string>
<string name="cd_scroll_to_bottom">নিচে স্ক্রল করুন</string>
<string name="cd_file">ফাইল</string>
<string name="cd_image">ছবি</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat আপনার অবস্থান ট্র্যাক করে না</string>
<string name="nobody_around">কেউ আশেপাশে নেই …</string>
<string name="you_suffix"> (আপনি)</string>
<string name="pan_zoom_instruction">জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন</string>
<string name="select">নির্বাচন করুন</string>
<string name="type_a_message_placeholder">বার্তা টাইপ করুন …</string>
<string name="mention">উল্লেখ</string>
<string name="version_prefix">v%1$s</string>
@ -382,4 +379,14 @@
<string name="about_select_language">ভাষা নির্বাচন করুন</string>
<string name="about_spam_filter_title">স্প্যাম ফিল্টার</string>
<string name="about_spam_filter_desc">যারা খুব দ্রুত বার্তা পাঠান, একই কথা বারবার বলেন বা অতিরিক্ত লিঙ্ক পাঠান, তাঁদের প্রকাশ্য বার্তা লুকান।</string>
<string name="globe_live">লাইভ</string>
<string name="globe_connecting">সংযুক্ত হচ্ছে</string>
<string name="globe_offline">অফলাইন</string>
<string name="globe_hint_drag">ঘোরান</string>
<string name="globe_hint_pinch">জুম</string>
<string name="globe_hint_tap">একটি বিন্দুতে ট্যাপ করুন</string>
<string name="globe_no_activity">সাম্প্রতিক কোনো কার্যকলাপ নেই</string>
<string name="cd_show_whole_globe">পুরো গ্লোব দেখান</string>
<string name="cd_globe_activity">%1$dটি সক্রিয় চ্যাট, %2$d জন অনলাইনে</string>
<string name="cd_channel_activity">%1$d জন, গত এক ঘণ্টায় %2$dটি বার্তা</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Favorit (offline)</string>
<string name="cd_decrease_precision">Genauigkeit verringern</string>
<string name="cd_increase_precision">Genauigkeit erhöhen</string>
<string name="cd_select_geohash">Geohash auswählen</string>
<string name="cd_scroll_to_bottom">Nach unten scrollen</string>
<string name="cd_file">Datei</string>
<string name="cd_image">Bild</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat verfolgt NICHT deinen Standort</string>
<string name="nobody_around">niemand da …</string>
<string name="you_suffix"> (du)</string>
<string name="pan_zoom_instruction">schwenken und zoomen, um einen Geohash auszuwählen</string>
<string name="select">auswählen</string>
<string name="type_a_message_placeholder">Nachricht eingeben …</string>
<string name="mention">erwähnen</string>
<string name="version_prefix">v%1$s</string>
@ -396,4 +393,14 @@
<string name="about_select_language">Sprache auswählen</string>
<string name="about_spam_filter_title">Spamfilter</string>
<string name="about_spam_filter_desc">Öffentliche Nachrichten von Personen ausblenden, die zu schnell schreiben, sich wiederholen oder zu viele Links senden.</string>
<string name="globe_live">live</string>
<string name="globe_connecting">verbinde</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">drehen</string>
<string name="globe_hint_pinch">zoomen</string>
<string name="globe_hint_tap">Punkt antippen</string>
<string name="globe_no_activity">keine aktuelle Aktivität</string>
<string name="cd_show_whole_globe">Ganzen Globus anzeigen</string>
<string name="cd_globe_activity">%1$d aktive Chats, %2$d Personen online</string>
<string name="cd_channel_activity">%1$d Personen, %2$d Nachrichten in der letzten Stunde</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Favorito (sin conexión)</string>
<string name="cd_decrease_precision">Disminuir precisión</string>
<string name="cd_increase_precision">Aumentar precisión</string>
<string name="cd_select_geohash">Seleccionar geohash</string>
<string name="cd_scroll_to_bottom">Desplazar al final</string>
<string name="cd_file">Archivo</string>
<string name="cd_image">Imagen</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat NO rastrea tu ubicación</string>
<string name="nobody_around">nadie por aquí…</string>
<string name="you_suffix"> (tú)</string>
<string name="pan_zoom_instruction">desplaza y hace zoom para seleccionar un geohash</string>
<string name="select">seleccionar</string>
<string name="type_a_message_placeholder">Escribe un mensaje…</string>
<string name="mention">mencionar</string>
<string name="version_prefix">v%1$s</string>
@ -395,4 +392,14 @@
<string name="about_select_language">Seleccionar idioma</string>
<string name="about_spam_filter_title">Filtro de spam</string>
<string name="about_spam_filter_desc">Oculta los mensajes públicos de quienes publican demasiado rápido, se repiten o envían demasiados enlaces.</string>
<string name="globe_live">en vivo</string>
<string name="globe_connecting">conectando</string>
<string name="globe_offline">sin conexión</string>
<string name="globe_hint_drag">girar</string>
<string name="globe_hint_pinch">zoom</string>
<string name="globe_hint_tap">toca un punto</string>
<string name="globe_no_activity">sin actividad reciente</string>
<string name="cd_show_whole_globe">Mostrar todo el globo</string>
<string name="cd_globe_activity">%1$d chats activos, %2$d personas en línea</string>
<string name="cd_channel_activity">%1$d personas, %2$d mensajes en la última hora</string>
</resources>

View File

@ -342,7 +342,6 @@
<string name="cd_offline_favorite">Lineaz kanpoko gogokoa</string>
<string name="cd_decrease_precision">Txikitu zehaztasuna</string>
<string name="cd_increase_precision">Handitu zehaztasuna</string>
<string name="cd_select_geohash">Hautatu geohash-a</string>
<string name="cd_scroll_to_bottom">Joan behera</string>
<string name="cd_file">Fitxategia</string>
<string name="cd_image">Irudia</string>
@ -556,8 +555,6 @@
<string name="cd_open_about">Ireki Honi buruz</string>
<string name="nobody_around">Ez dago inor inguruan…</string>
<string name="you_suffix"> (zu)</string>
<string name="pan_zoom_instruction">Arrastatu biratzeko · Atximurkatu zoom egiteko · Sakatu fokatzeko</string>
<string name="select">Hautatu</string>
<string name="type_a_message_placeholder">Idatzi mezu bat…</string>
<string name="mention">Aipatu</string>
<string name="media_type_image">Irudia</string>
@ -625,4 +622,14 @@
<string name="image_star">image/*</string>
<string name="about_spam_filter_title">Spam-iragazkia</string>
<string name="about_spam_filter_desc">Ezkutatu azkarregi idazten duten, mezuak errepikatzen dituzten edo esteka gehiegi bidaltzen dituzten pertsonen mezu publikoak.</string>
<string name="globe_live">zuzenean</string>
<string name="globe_connecting">konektatzen</string>
<string name="globe_offline">lineaz kanpo</string>
<string name="globe_hint_drag">biratu</string>
<string name="globe_hint_pinch">zooma</string>
<string name="globe_hint_tap">sakatu puntu bat</string>
<string name="globe_no_activity">ez dago azken jarduerarik</string>
<string name="cd_show_whole_globe">Erakutsi globo osoa</string>
<string name="cd_globe_activity">%1$d txat aktibo, %2$d pertsona konektatuta</string>
<string name="cd_channel_activity">%1$d pertsona, %2$d mezu azken orduan</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">دلخواه (آفلاین)</string>
<string name="cd_decrease_precision">کاهش دقت</string>
<string name="cd_increase_precision">افزایش دقت</string>
<string name="cd_select_geohash">انتخاب geohash</string>
<string name="cd_scroll_to_bottom">اسکرول به پایین</string>
<string name="cd_file">فایل</string>
<string name="cd_image">تصویر</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat موقعیت شما را پیگیری نمی‌کند</string>
<string name="nobody_around">کسی در اطراف نیست …</string>
<string name="you_suffix"> (شما)</string>
<string name="pan_zoom_instruction">برای انتخاب geohash، جابه‌جا و زوم کنید</string>
<string name="select">انتخاب</string>
<string name="type_a_message_placeholder">نوشتن پیام …</string>
<string name="mention">ذکر</string>
<string name="version_prefix">v%1$s</string>
@ -382,4 +379,14 @@
<string name="about_select_language">انتخاب زبان</string>
<string name="about_spam_filter_title">فیلتر هرزنامه</string>
<string name="about_spam_filter_desc">پیام‌های عمومی افرادی را که خیلی سریع پیام می‌فرستند، خود را تکرار می‌کنند یا پیوندهای زیادی می‌فرستند پنهان کن.</string>
<string name="globe_live">زنده</string>
<string name="globe_connecting">در حال اتصال</string>
<string name="globe_offline">آفلاین</string>
<string name="globe_hint_drag">چرخش</string>
<string name="globe_hint_pinch">بزرگ‌نمایی</string>
<string name="globe_hint_tap">روی یک نقطه بزنید</string>
<string name="globe_no_activity">فعالیت اخیری نیست</string>
<string name="cd_show_whole_globe">نمایش کل کره زمین</string>
<string name="cd_globe_activity">%1$d گفتگوی فعال، %2$d نفر آنلاین</string>
<string name="cd_channel_activity">%1$d نفر، %2$d پیام در ساعت گذشته</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Paborito (offline)</string>
<string name="cd_decrease_precision">Bawasan ang precision</string>
<string name="cd_increase_precision">Dagdagan ang precision</string>
<string name="cd_select_geohash">Piliin ang geohash</string>
<string name="cd_scroll_to_bottom">Mag‑scroll pababa</string>
<string name="cd_file">File</string>
<string name="cd_image">Larawan</string>
@ -275,8 +274,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">walang tao sa paligid…</string>
<string name="you_suffix"> (ikaw)</string>
<string name="pan_zoom_instruction">i‑pan at i‑zoom para pumili ng geohash</string>
<string name="select">piliin</string>
<string name="type_a_message_placeholder">magtype ng mensahe…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">banggit</string>
@ -393,4 +390,14 @@
<string name="about_select_language">Pumili ng wika</string>
<string name="about_spam_filter_title">Filter ng spam</string>
<string name="about_spam_filter_desc">Itago ang mga pampublikong mensahe mula sa mga taong masyadong mabilis mag-post, paulit-ulit ang sinasabi, o nagpapadala ng napakaraming link.</string>
<string name="globe_live">live</string>
<string name="globe_connecting">kumokonekta</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">iikot</string>
<string name="globe_hint_pinch">zoom</string>
<string name="globe_hint_tap">i-tap ang tuldok</string>
<string name="globe_no_activity">walang kamakailang aktibidad</string>
<string name="cd_show_whole_globe">Ipakita ang buong globo</string>
<string name="cd_globe_activity">%1$d aktibong chat, %2$d tao ang online</string>
<string name="cd_channel_activity">%1$d tao, %2$d mensahe sa nakaraang oras</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Favori (hors‑ligne)</string>
<string name="cd_decrease_precision">Diminuer la précision</string>
<string name="cd_increase_precision">Augmenter la précision</string>
<string name="cd_select_geohash">Sélectionner le geohash</string>
<string name="cd_scroll_to_bottom">Aller en bas</string>
<string name="cd_file">Fichier</string>
<string name="cd_image">Image</string>
@ -276,8 +275,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">personne aux alentours…</string>
<string name="you_suffix"> (toi)</string>
<string name="pan_zoom_instruction">déplacer et zoomer pour choisir un geohash</string>
<string name="select">sélectionner</string>
<string name="type_a_message_placeholder">écrire un message…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">mention</string>
@ -407,4 +404,14 @@
<string name="about_select_language">Choisir la langue</string>
<string name="about_spam_filter_title">Filtre antispam</string>
<string name="about_spam_filter_desc">Masquer les messages publics des personnes qui publient trop vite, se répètent ou inondent le chat de liens.</string>
<string name="globe_live">en direct</string>
<string name="globe_connecting">connexion</string>
<string name="globe_offline">hors ligne</string>
<string name="globe_hint_drag">tourner</string>
<string name="globe_hint_pinch">zoomer</string>
<string name="globe_hint_tap">touchez un point</string>
<string name="globe_no_activity">aucune activité récente</string>
<string name="cd_show_whole_globe">Afficher tout le globe</string>
<string name="cd_globe_activity">%1$d discussions actives, %2$d personnes en ligne</string>
<string name="cd_channel_activity">%1$d personnes, %2$d messages au cours de la dernière heure</string>
</resources>

View File

@ -198,7 +198,6 @@
<string name="cd_offline_favorite">מועדף לא מקוון</string>
<string name="cd_decrease_precision">הפחת דיוק</string>
<string name="cd_increase_precision">הגבר דיוק</string>
<string name="cd_select_geohash">בחר geohash</string>
<string name="cd_scroll_to_bottom">גלול לתחתית</string>
<string name="cd_file">קובץ</string>
<string name="cd_image">תמונה</string>
@ -333,8 +332,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">אין אף אחד בסביבה...</string>
<string name="you_suffix"> (אתה)</string>
<string name="pan_zoom_instruction">הזז ושנה תצוגה כדי לבחור geohash</string>
<string name="select">בחר</string>
<string name="type_a_message_placeholder">הקלד הודעה...</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">אזכור</string>
@ -394,4 +391,14 @@
<string name="about_select_language">בחירת שפה</string>
<string name="about_spam_filter_title">מסנן ספאם</string>
<string name="about_spam_filter_desc">הסתר הודעות ציבוריות מאנשים ששולחים מהר מדי, חוזרים על עצמם או מציפים בקישורים.</string>
<string name="globe_live">חי</string>
<string name="globe_connecting">מתחבר</string>
<string name="globe_offline">לא מחובר</string>
<string name="globe_hint_drag">סיבוב</string>
<string name="globe_hint_pinch">זום</string>
<string name="globe_hint_tap">הקש על נקודה</string>
<string name="globe_no_activity">אין פעילות אחרונה</string>
<string name="cd_show_whole_globe">הצג את כל הגלובוס</string>
<string name="cd_globe_activity">%1$d צ׳אטים פעילים, %2$d אנשים מחוברים</string>
<string name="cd_channel_activity">%1$d אנשים, %2$d הודעות בשעה האחרונה</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">पसंदीदा (ऑफ़लाइन)</string>
<string name="cd_decrease_precision">सटीकता घटाएँ</string>
<string name="cd_increase_precision">सटीकता बढ़ाएँ</string>
<string name="cd_select_geohash">geohash चुनें</string>
<string name="cd_scroll_to_bottom">नीचे स्क्रॉल करें</string>
<string name="cd_file">फ़ाइल</string>
<string name="cd_image">छवि</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat आपकी लोकेशन ट्रैक नहीं करती</string>
<string name="nobody_around">आस‑पास कोई नहीं…</string>
<string name="you_suffix"> (आप)</string>
<string name="pan_zoom_instruction">geohash चुनने के लिए पैन/ज़ूम करें</string>
<string name="select">चयन करें</string>
<string name="type_a_message_placeholder">संदेश लिखें…</string>
<string name="mention">उल्लेख</string>
<string name="version_prefix">v%1$s</string>
@ -395,4 +392,14 @@
<string name="about_select_language">भाषा चुनें</string>
<string name="about_spam_filter_title">स्पैम फ़िल्टर</string>
<string name="about_spam_filter_desc">बहुत तेज़ी से संदेश भेजने, एक ही बात दोहराने या बहुत अधिक लिंक भेजने वाले लोगों के सार्वजनिक संदेश छिपाएँ।</string>
<string name="globe_live">लाइव</string>
<string name="globe_connecting">कनेक्ट हो रहा है</string>
<string name="globe_offline">ऑफ़लाइन</string>
<string name="globe_hint_drag">घुमाएँ</string>
<string name="globe_hint_pinch">ज़ूम</string>
<string name="globe_hint_tap">किसी बिंदु पर टैप करें</string>
<string name="globe_no_activity">हाल की कोई गतिविधि नहीं</string>
<string name="cd_show_whole_globe">पूरा ग्लोब दिखाएँ</string>
<string name="cd_globe_activity">%1$d सक्रिय चैट, %2$d लोग ऑनलाइन</string>
<string name="cd_channel_activity">%1$d लोग, पिछले घंटे में %2$d संदेश</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Favorit (offline)</string>
<string name="cd_decrease_precision">Kurangi presisi</string>
<string name="cd_increase_precision">Tingkatkan presisi</string>
<string name="cd_select_geohash">Pilih geohash</string>
<string name="cd_scroll_to_bottom">Gulir ke bawah</string>
<string name="cd_file">File</string>
<string name="cd_image">Gambar</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat TIDAK melacak lokasi Anda</string>
<string name="nobody_around">tidak ada orang di sekitar …</string>
<string name="you_suffix"> (Anda)</string>
<string name="pan_zoom_instruction">geser dan zoom untuk memilih geohash</string>
<string name="select">pilih</string>
<string name="type_a_message_placeholder">Ketik pesan …</string>
<string name="mention">sebut</string>
<string name="version_prefix">v%1$s</string>
@ -395,4 +392,14 @@
<string name="about_select_language">Pilih bahasa</string>
<string name="about_spam_filter_title">Filter spam</string>
<string name="about_spam_filter_desc">Sembunyikan pesan publik dari orang yang mengirim terlalu cepat, mengulang pesan, atau membanjiri obrolan dengan tautan.</string>
<string name="globe_live">langsung</string>
<string name="globe_connecting">menghubungkan</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">putar</string>
<string name="globe_hint_pinch">zoom</string>
<string name="globe_hint_tap">ketuk titik</string>
<string name="globe_no_activity">tidak ada aktivitas terbaru</string>
<string name="cd_show_whole_globe">Tampilkan seluruh globe</string>
<string name="cd_globe_activity">%1$d obrolan aktif, %2$d orang online</string>
<string name="cd_channel_activity">%1$d orang, %2$d pesan dalam satu jam terakhir</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Preferito offline</string>
<string name="cd_decrease_precision">Diminuisci precisione</string>
<string name="cd_increase_precision">Aumenta precisione</string>
<string name="cd_select_geohash">Seleziona geohash</string>
<string name="cd_scroll_to_bottom">Scorri in fondo</string>
<string name="cd_file">File</string>
<string name="cd_image">Immagine</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat NON traccia la tua posizione</string>
<string name="nobody_around">nessuno nei dintorni…</string>
<string name="you_suffix"> (tu)</string>
<string name="pan_zoom_instruction">scorri e ingrandisci per selezionare un geohash</string>
<string name="select">seleziona</string>
<string name="type_a_message_placeholder">scrivi un messaggio…</string>
<string name="mention">menzione</string>
<string name="version_prefix">v%1$s</string>
@ -422,4 +419,14 @@
<string name="about_select_language">Seleziona lingua</string>
<string name="about_spam_filter_title">Filtro antispam</string>
<string name="about_spam_filter_desc">Nascondi i messaggi pubblici di chi scrive troppo velocemente, si ripete o invia troppi link.</string>
<string name="globe_live">live</string>
<string name="globe_connecting">connessione</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">ruota</string>
<string name="globe_hint_pinch">zoom</string>
<string name="globe_hint_tap">tocca un punto</string>
<string name="globe_no_activity">nessuna attività recente</string>
<string name="cd_show_whole_globe">Mostra tutto il globo</string>
<string name="cd_globe_activity">%1$d chat attive, %2$d persone online</string>
<string name="cd_channel_activity">%1$d persone, %2$d messaggi nell\'ultima ora</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">お気に入り (オフライン)</string>
<string name="cd_decrease_precision">精度を下げる</string>
<string name="cd_increase_precision">精度を上げる</string>
<string name="cd_select_geohash">Geohash を選択</string>
<string name="cd_scroll_to_bottom">一番下へスクロール</string>
<string name="cd_file">ファイル</string>
<string name="cd_image">画像</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat はあなたの位置情報を追跡しません</string>
<string name="nobody_around">誰もいません…</string>
<string name="you_suffix"> (あなた)</string>
<string name="pan_zoom_instruction">パン/ズームして geohash を選択</string>
<string name="select">選択</string>
<string name="type_a_message_placeholder">メッセージを入力…</string>
<string name="mention">メンション</string>
<string name="version_prefix">v%1$s</string>
@ -395,4 +392,14 @@
<string name="about_select_language">言語を選択</string>
<string name="about_spam_filter_title">スパムフィルター</string>
<string name="about_spam_filter_desc">短時間に大量のメッセージを送信したり、同じ内容を繰り返したり、リンクを大量に投稿したりする人の公開メッセージを非表示にします。</string>
<string name="globe_live">ライブ</string>
<string name="globe_connecting">接続中</string>
<string name="globe_offline">オフライン</string>
<string name="globe_hint_drag">回転</string>
<string name="globe_hint_pinch">ズーム</string>
<string name="globe_hint_tap">ドットをタップ</string>
<string name="globe_no_activity">最近のアクティビティなし</string>
<string name="cd_show_whole_globe">地球全体を表示</string>
<string name="cd_globe_activity">アクティブなチャット %1$d 件、オンライン %2$d 人</string>
<string name="cd_channel_activity">%1$d 人、過去1時間のメッセージ %2$d 件</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">რჩეული (ოფლაინ)</string>
<string name="cd_decrease_precision">სიზუსტის შემცირება</string>
<string name="cd_increase_precision">სიზუსტის გაზრდა</string>
<string name="cd_select_geohash">აირჩიეთ geohash</string>
<string name="cd_scroll_to_bottom">ქვემოთ გადახვევა</string>
<string name="cd_file">ფაილი</string>
<string name="cd_image">სურათი</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat არ აკონტროლებს თქვენს მდებარეობას</string>
<string name="nobody_around">არავინ არის ახლოს…</string>
<string name="you_suffix"> (თქვენ)</string>
<string name="pan_zoom_instruction">პანორამირება/ზუმი geohash-ის ასარჩევად</string>
<string name="select">არჩევა</string>
<string name="type_a_message_placeholder">შეიყვანეთ შეტყობინება…</string>
<string name="mention">ხსენება</string>
<string name="version_prefix">v%1$s</string>
@ -382,4 +379,14 @@
<string name="about_select_language">ენის არჩევა</string>
<string name="about_spam_filter_title">სპამის ფილტრი</string>
<string name="about_spam_filter_desc">დამალე იმ ადამიანების საჯარო შეტყობინებები, რომლებიც ზედმეტად სწრაფად წერენ, იმეორებენ შეტყობინებებს ან აგზავნიან ზედმეტად ბევრ ბმულს.</string>
<string name="globe_live">ლაივი</string>
<string name="globe_connecting">უკავშირდება</string>
<string name="globe_offline">ხაზგარეშე</string>
<string name="globe_hint_drag">ტრიალი</string>
<string name="globe_hint_pinch">მასშტაბი</string>
<string name="globe_hint_tap">შეეხეთ წერტილს</string>
<string name="globe_no_activity">ბოლო აქტივობა არ არის</string>
<string name="cd_show_whole_globe">მთელი გლობუსის ჩვენება</string>
<string name="cd_globe_activity">%1$d აქტიური ჩატი, %2$d ადამიანი ონლაინ</string>
<string name="cd_channel_activity">%1$d ადამიანი, %2$d შეტყობინება ბოლო საათში</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">즐겨찾기 (오프라인)</string>
<string name="cd_decrease_precision">정밀도 낮추기</string>
<string name="cd_increase_precision">정밀도 높이기</string>
<string name="cd_select_geohash">geohash 선택</string>
<string name="cd_scroll_to_bottom">아래로 스크롤</string>
<string name="cd_file">파일</string>
<string name="cd_image">이미지</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat은 사용자의 위치를 추적하지 않습니다</string>
<string name="nobody_around">주변에 아무도 없음 …</string>
<string name="you_suffix"> (나)</string>
<string name="pan_zoom_instruction">geohash를 선택하려면 이동하고 확대/축소하세요</string>
<string name="select">선택</string>
<string name="type_a_message_placeholder">메시지 입력 …</string>
<string name="mention">멘션</string>
<string name="version_prefix">v%1$s</string>
@ -395,4 +392,14 @@
<string name="about_select_language">언어 선택</string>
<string name="about_spam_filter_title">스팸 필터</string>
<string name="about_spam_filter_desc">너무 빠르게 메시지를 보내거나, 같은 내용을 반복하거나, 링크를 과도하게 보내는 사람의 공개 메시지를 숨깁니다.</string>
<string name="globe_live">라이브</string>
<string name="globe_connecting">연결 중</string>
<string name="globe_offline">오프라인</string>
<string name="globe_hint_drag">회전</string>
<string name="globe_hint_pinch">확대</string>
<string name="globe_hint_tap">점을 탭하세요</string>
<string name="globe_no_activity">최근 활동 없음</string>
<string name="cd_show_whole_globe">지구 전체 보기</string>
<string name="cd_globe_activity">활성 채팅 %1$d개, 온라인 %2$d명</string>
<string name="cd_channel_activity">%1$d명, 지난 1시간 메시지 %2$d개</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Ankafizina tsy an-tserasera</string>
<string name="cd_decrease_precision">Hampihenana ny fahamarinana</string>
<string name="cd_increase_precision">Hampitombo ny fahamarinana</string>
<string name="cd_select_geohash">Hifidy geohash</string>
<string name="cd_scroll_to_bottom">Hidina any ambany</string>
<string name="cd_file">Rakitra</string>
<string name="cd_image">Sary</string>
@ -284,8 +283,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">tsy misy olona manodidina...</string>
<string name="you_suffix"> (ianao)</string>
<string name="pan_zoom_instruction">ahetsika sy ataovy lehibe mba hifidianana geohash</string>
<string name="select">hifidy</string>
<string name="type_a_message_placeholder">soraty hafatra...</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">hanonona</string>
@ -402,4 +399,14 @@
<string name="about_select_language">Safidio ny fiteny</string>
<string name="about_spam_filter_title">Sivana spam</string>
<string name="about_spam_filter_desc">Afeno ny hafatra ampahibemaso avy amin’ireo olona mandefa haingana loatra, mamerimberina hafatra, na mandefa rohy be loatra.</string>
<string name="globe_live">mivantana</string>
<string name="globe_connecting">mampifandray</string>
<string name="globe_offline">tsy mifandray</string>
<string name="globe_hint_drag">ahodino</string>
<string name="globe_hint_pinch">hanalehibe</string>
<string name="globe_hint_tap">tsindrio teboka iray</string>
<string name="globe_no_activity">tsy misy hetsika vao haingana</string>
<string name="cd_show_whole_globe">Asehoy ny tany manontolo</string>
<string name="cd_globe_activity">resaka mavitrika %1$d, olona %2$d an-tserasera</string>
<string name="cd_channel_activity">olona %1$d, hafatra %2$d tao anatin\'ny ora farany</string>
</resources>

View File

@ -158,7 +158,6 @@
<string name="cd_offline_favorite">Kegemaran luar talian</string>
<string name="cd_decrease_precision">Kurangkan ketepatan</string>
<string name="cd_increase_precision">Tingkatkan ketepatan</string>
<string name="cd_select_geohash">Pilih geohash</string>
<string name="cd_scroll_to_bottom">Tatal ke bawah</string>
<string name="cd_file">Fail</string>
<string name="cd_image">Imej</string>
@ -356,8 +355,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">tiada sesiapa berdekatan...</string>
<string name="you_suffix"> (anda)</string>
<string name="pan_zoom_instruction">gerak dan zum untuk memilih geohash</string>
<string name="select">pilih</string>
<string name="type_a_message_placeholder">taip mesej...</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">sebutan</string>
@ -435,4 +432,14 @@
<string name="about_select_language">Pilih bahasa</string>
<string name="about_spam_filter_title">Penapis spam</string>
<string name="about_spam_filter_desc">Sembunyikan mesej awam daripada orang yang menghantar terlalu pantas, mengulang mesej atau membanjiri perbualan dengan pautan.</string>
<string name="globe_live">langsung</string>
<string name="globe_connecting">menyambung</string>
<string name="globe_offline">luar talian</string>
<string name="globe_hint_drag">putar</string>
<string name="globe_hint_pinch">zum</string>
<string name="globe_hint_tap">ketik satu titik</string>
<string name="globe_no_activity">tiada aktiviti terkini</string>
<string name="cd_show_whole_globe">Tunjukkan seluruh glob</string>
<string name="cd_globe_activity">%1$d sembang aktif, %2$d orang dalam talian</string>
<string name="cd_channel_activity">%1$d orang, %2$d mesej dalam sejam lepas</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">मनपर्ने (अफलाइन)</string>
<string name="cd_decrease_precision">शुद्धता घटाउनुहोस्</string>
<string name="cd_increase_precision">शुद्धता बढाउनुहोस्</string>
<string name="cd_select_geohash">geohash छान्नुहोस्</string>
<string name="cd_scroll_to_bottom">तल स्क्रोल गर्नुहोस्</string>
<string name="cd_file">फाइल</string>
<string name="cd_image">तस्वीर</string>
@ -275,8 +274,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">वरिपरि कोही छैन…</string>
<string name="you_suffix"> (तपाईं)</string>
<string name="pan_zoom_instruction">geohash छान्न प्यान र जुम गर्नुहोस्</string>
<string name="select">छान्नुहोस्</string>
<string name="type_a_message_placeholder">सन्देश टाइप गर्नुहोस्…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">उल्लेख</string>
@ -393,4 +390,14 @@
<string name="about_select_language">भाषा छान्नुहोस्</string>
<string name="about_spam_filter_title">स्प्याम फिल्टर</string>
<string name="about_spam_filter_desc">धेरै छिटो सन्देश पठाउने, एउटै कुरा दोहोर्‍याउने वा धेरै लिङ्क पठाउने व्यक्तिका सार्वजनिक सन्देशहरू लुकाउनुहोस्।</string>
<string name="globe_live">लाइभ</string>
<string name="globe_connecting">जडान हुँदै</string>
<string name="globe_offline">अफलाइन</string>
<string name="globe_hint_drag">घुमाउनुहोस्</string>
<string name="globe_hint_pinch">जुम</string>
<string name="globe_hint_tap">एउटा बिन्दुमा ट्याप गर्नुहोस्</string>
<string name="globe_no_activity">हालको कुनै गतिविधि छैन</string>
<string name="cd_show_whole_globe">पूरै ग्लोब देखाउनुहोस्</string>
<string name="cd_globe_activity">%1$d सक्रिय च्याट, %2$d जना अनलाइन</string>
<string name="cd_channel_activity">%1$d जना, पछिल्लो एक घण्टामा %2$d सन्देश</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Offline favoriet</string>
<string name="cd_decrease_precision">Precisie verlagen</string>
<string name="cd_increase_precision">Precisie verhogen</string>
<string name="cd_select_geohash">Geohash selecteren</string>
<string name="cd_scroll_to_bottom">Naar beneden scrollen</string>
<string name="cd_file">Bestand</string>
<string name="cd_image">Afbeelding</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat volgt NIET uw locatie</string>
<string name="nobody_around">niemand in de buurt…</string>
<string name="you_suffix"> (jij)</string>
<string name="pan_zoom_instruction">pan en zoom om een geohash te selecteren</string>
<string name="select">selecteren</string>
<string name="type_a_message_placeholder">typ een bericht…</string>
<string name="mention">vermelden</string>
<string name="version_prefix">v%1$s</string>
@ -420,4 +417,14 @@
<string name="about_select_language">Taal selecteren</string>
<string name="about_spam_filter_title">Spamfilter</string>
<string name="about_spam_filter_desc">Verberg openbare berichten van mensen die te snel posten, zichzelf herhalen of te veel links sturen.</string>
<string name="globe_live">live</string>
<string name="globe_connecting">verbinden</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">draaien</string>
<string name="globe_hint_pinch">zoomen</string>
<string name="globe_hint_tap">tik op een stip</string>
<string name="globe_no_activity">geen recente activiteit</string>
<string name="cd_show_whole_globe">Hele wereldbol tonen</string>
<string name="cd_globe_activity">%1$d actieve chats, %2$d mensen online</string>
<string name="cd_channel_activity">%1$d mensen, %2$d berichten in het afgelopen uur</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">پسندیدہ (آف لائن)</string>
<string name="cd_decrease_precision">درستگی گھٹاؤ</string>
<string name="cd_increase_precision">درستگی وداؤ</string>
<string name="cd_select_geohash">geohash چُنو</string>
<string name="cd_scroll_to_bottom">تھلے سکرول کرو</string>
<string name="cd_file">فائل</string>
<string name="cd_image">تصویر</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat توانڈی لوکیشن نئیں ٹریک کردا</string>
<string name="nobody_around">کوئی وی نیڑے نئیں …</string>
<string name="you_suffix"> (تُسی)</string>
<string name="pan_zoom_instruction">geohash چُنن لئی پین تے زوم کرو</string>
<string name="select">چُنو</string>
<string name="type_a_message_placeholder">پیغام لکھو …</string>
<string name="mention">ذکر</string>
<string name="version_prefix">v%1$s</string>
@ -382,4 +379,14 @@
<string name="about_select_language">بولی چݨو</string>
<string name="about_spam_filter_title">سپیم فلٹر</string>
<string name="about_spam_filter_desc">اوہناں لوکاں دے عوامی پیغام لُکاؤ جو بہت تیزی نال پیغام بھیجدے، گلّاں دُہراوندے یا بہت سارے لنک بھیجدے نیں۔</string>
<string name="globe_live">لائیو</string>
<string name="globe_connecting">جڑ رہیا اے</string>
<string name="globe_offline">آف لائن</string>
<string name="globe_hint_drag">گھماؤ</string>
<string name="globe_hint_pinch">زوم</string>
<string name="globe_hint_tap">کسے بندو تے ٹیپ کرو</string>
<string name="globe_no_activity">کوئی تازہ سرگرمی نہیں</string>
<string name="cd_show_whole_globe">پورا گلوب وکھاؤ</string>
<string name="cd_globe_activity">%1$d فعال چیٹاں، %2$d لوک آن لائن</string>
<string name="cd_channel_activity">%1$d لوک، پچھلے گھنٹے وچ %2$d سنیہے</string>
</resources>

View File

@ -196,7 +196,6 @@
<string name="cd_offline_favorite">Ulubiony offline</string>
<string name="cd_decrease_precision">Zmniejsz precyzję</string>
<string name="cd_increase_precision">Zwiększ precyzję</string>
<string name="cd_select_geohash">Wybierz geohash</string>
<string name="cd_scroll_to_bottom">Przewiń na dół</string>
<string name="cd_file">Plik</string>
<string name="cd_image">Obraz</string>
@ -331,8 +330,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">nikogo w pobliżu...</string>
<string name="you_suffix"> (ty)</string>
<string name="pan_zoom_instruction">przesuwaj i powiększaj, aby wybrać geohash</string>
<string name="select">wybierz</string>
<string name="type_a_message_placeholder">napisz wiadomość...</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">wzmianka</string>
@ -400,4 +397,14 @@
<string name="about_select_language">Wybierz język</string>
<string name="about_spam_filter_title">Filtr spamu</string>
<string name="about_spam_filter_desc">Ukrywaj publiczne wiadomości od osób, które piszą zbyt szybko, powtarzają się lub wysyłają zbyt wiele linków.</string>
<string name="globe_live">na żywo</string>
<string name="globe_connecting">łączenie</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">obracaj</string>
<string name="globe_hint_pinch">przybliż</string>
<string name="globe_hint_tap">dotknij kropki</string>
<string name="globe_no_activity">brak ostatniej aktywności</string>
<string name="cd_show_whole_globe">Pokaż cały glob</string>
<string name="cd_globe_activity">Aktywne czaty: %1$d, osoby online: %2$d</string>
<string name="cd_channel_activity">Osoby: %1$d, wiadomości w ostatniej godzinie: %2$d</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Favorito (offline)</string>
<string name="cd_decrease_precision">Diminuir precisão</string>
<string name="cd_increase_precision">Aumentar precisão</string>
<string name="cd_select_geohash">Selecionar geohash</string>
<string name="cd_scroll_to_bottom">Rolar para o fim</string>
<string name="cd_file">Arquivo</string>
<string name="cd_image">Imagem</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">O bitchat NÃO rastreia sua localização</string>
<string name="nobody_around">ninguém por perto…</string>
<string name="you_suffix"> (você)</string>
<string name="pan_zoom_instruction">arraste/amplie para selecionar um geohash</string>
<string name="select">selecionar</string>
<string name="type_a_message_placeholder">Digite uma mensagem…</string>
<string name="mention">mencionar</string>
<string name="version_prefix">v%1$s</string>
@ -394,4 +391,14 @@
<string name="about_select_language">Selecionar idioma</string>
<string name="about_spam_filter_title">Filtro de spam</string>
<string name="about_spam_filter_desc">Ocultar mensagens públicas de pessoas que enviam mensagens rápido demais, se repetem ou enviam links em excesso.</string>
<string name="globe_live">ao vivo</string>
<string name="globe_connecting">conectando</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">girar</string>
<string name="globe_hint_pinch">zoom</string>
<string name="globe_hint_tap">toque em um ponto</string>
<string name="globe_no_activity">sem atividade recente</string>
<string name="cd_show_whole_globe">Mostrar o globo inteiro</string>
<string name="cd_globe_activity">%1$d chats ativos, %2$d pessoas online</string>
<string name="cd_channel_activity">%1$d pessoas, %2$d mensagens na última hora</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Favorito (offline)</string>
<string name="cd_decrease_precision">Diminuir precisão</string>
<string name="cd_increase_precision">Aumentar precisão</string>
<string name="cd_select_geohash">Selecionar geohash</string>
<string name="cd_scroll_to_bottom">Deslocar para baixo</string>
<string name="cd_file">Ficheiro</string>
<string name="cd_image">Imagem</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">O bitchat NÃO rastreia a sua localização</string>
<string name="nobody_around">ninguém por perto…</string>
<string name="you_suffix"> (você)</string>
<string name="pan_zoom_instruction">arraste e amplie para selecionar um geohash</string>
<string name="select">selecionar</string>
<string name="type_a_message_placeholder">Digite uma mensagem…</string>
<string name="mention">mencionar</string>
<string name="version_prefix">v%1$s</string>
@ -395,4 +392,14 @@
<string name="about_select_language">Selecionar idioma</string>
<string name="about_spam_filter_title">Filtro de spam</string>
<string name="about_spam_filter_desc">Ocultar mensagens públicas de pessoas que publicam demasiado depressa, se repetem ou enviam demasiadas ligações.</string>
<string name="globe_live">ao vivo</string>
<string name="globe_connecting">a ligar</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">rodar</string>
<string name="globe_hint_pinch">zoom</string>
<string name="globe_hint_tap">toque num ponto</string>
<string name="globe_no_activity">sem atividade recente</string>
<string name="cd_show_whole_globe">Mostrar o globo inteiro</string>
<string name="cd_globe_activity">%1$d conversas ativas, %2$d pessoas online</string>
<string name="cd_channel_activity">%1$d pessoas, %2$d mensagens na última hora</string>
</resources>

View File

@ -134,7 +134,6 @@
<string name="cd_offline_favorite">Избранное (оффлайн)</string>
<string name="cd_decrease_precision">Уменьшить точность</string>
<string name="cd_increase_precision">Увеличить точность</string>
<string name="cd_select_geohash">Выбрать geohash</string>
<string name="cd_scroll_to_bottom">Прокрутить вниз</string>
<string name="cd_file">Файл</string>
<string name="cd_image">Изображение</string>
@ -259,8 +258,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">никого рядом…</string>
<string name="you_suffix"> (вы)</string>
<string name="pan_zoom_instruction">прокрутите/масштабируйте для выбора geohash</string>
<string name="select">выбрать</string>
<string name="type_a_message_placeholder">введите сообщение…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">упоминание</string>
@ -382,4 +379,14 @@
<string name="about_select_language">Выбрать язык</string>
<string name="about_spam_filter_title">Спам-фильтр</string>
<string name="about_spam_filter_desc">Скрывать публичные сообщения от тех, кто пишет слишком быстро, повторяется или отправляет слишком много ссылок.</string>
<string name="globe_live">в эфире</string>
<string name="globe_connecting">подключение</string>
<string name="globe_offline">офлайн</string>
<string name="globe_hint_drag">вращать</string>
<string name="globe_hint_pinch">масштаб</string>
<string name="globe_hint_tap">нажмите на точку</string>
<string name="globe_no_activity">нет недавней активности</string>
<string name="cd_show_whole_globe">Показать весь глобус</string>
<string name="cd_globe_activity">Активных чатов: %1$d, людей онлайн: %2$d</string>
<string name="cd_channel_activity">Людей: %1$d, сообщений за последний час: %2$d</string>
</resources>

View File

@ -134,7 +134,6 @@
<string name="cd_offline_favorite">Favorit (offline)</string>
<string name="cd_decrease_precision">Minska precision</string>
<string name="cd_increase_precision">Öka precision</string>
<string name="cd_select_geohash">Välj geohash</string>
<string name="cd_scroll_to_bottom">Rulla längst ned</string>
<string name="cd_file">Fil</string>
<string name="cd_image">Bild</string>
@ -259,8 +258,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">ingen i närheten…</string>
<string name="you_suffix"> (du)</string>
<string name="pan_zoom_instruction">panorera och zooma för att välja geohash</string>
<string name="select">välj</string>
<string name="type_a_message_placeholder">skriv ett meddelande…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">nämn</string>
@ -380,4 +377,14 @@
<string name="about_select_language">Välj språk</string>
<string name="about_spam_filter_title">Spamfilter</string>
<string name="about_spam_filter_desc">Dölj offentliga meddelanden från personer som skriver för snabbt, upprepar sig eller skickar för många länkar.</string>
<string name="globe_live">live</string>
<string name="globe_connecting">ansluter</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">snurra</string>
<string name="globe_hint_pinch">zooma</string>
<string name="globe_hint_tap">tryck på en prick</string>
<string name="globe_no_activity">ingen aktivitet nyligen</string>
<string name="cd_show_whole_globe">Visa hela jordklotet</string>
<string name="cd_globe_activity">%1$d aktiva chattar, %2$d personer online</string>
<string name="cd_channel_activity">%1$d personer, %2$d meddelanden den senaste timmen</string>
</resources>

View File

@ -188,7 +188,6 @@
<string name="cd_offline_favorite">ஆஃப்லைன் பிடித்தவை</string>
<string name="cd_decrease_precision">துல்லியத்தைக் குறை</string>
<string name="cd_increase_precision">துல்லியத்தை அதிகரி</string>
<string name="cd_select_geohash">ஜியோஹாஷைத் தேர்ந்தெடு</string>
<string name="cd_scroll_to_bottom">கீழே ஸ்க்ரோல் செய்</string>
<string name="cd_file">கோப்பு</string>
<string name="cd_image">படம்</string>
@ -336,8 +335,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">யாரும் அருகில் இல்லை…</string>
<string name="you_suffix"> (நீங்கள்)</string>
<string name="pan_zoom_instruction">ஜியோஹாஷைத் தேர்ந்தெடுக்க பான் செய்து ஜூம் செய்யவும்</string>
<string name="select">தேர்ந்தெடு</string>
<string name="type_a_message_placeholder">செய்தியை உள்ளிடவும்…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">குறிப்பிடு</string>
@ -392,4 +389,14 @@
<string name="about_select_language">மொழியைத் தேர்ந்தெடுக்கவும்</string>
<string name="about_spam_filter_title">ஸ்பேம் வடிகட்டி</string>
<string name="about_spam_filter_desc">மிக வேகமாகச் செய்திகளை அனுப்புபவர்கள், ஒரே கருத்தைத் திரும்பத் திரும்பக் கூறுபவர்கள் அல்லது அதிக இணைப்புகளை அனுப்புபவர்களின் பொதுச் செய்திகளை மறைக்கவும்.</string>
<string name="globe_live">நேரலை</string>
<string name="globe_connecting">இணைக்கிறது</string>
<string name="globe_offline">ஆஃப்லைன்</string>
<string name="globe_hint_drag">சுழற்று</string>
<string name="globe_hint_pinch">பெரிதாக்கு</string>
<string name="globe_hint_tap">ஒரு புள்ளியைத் தட்டவும்</string>
<string name="globe_no_activity">சமீபத்திய செயல்பாடு இல்லை</string>
<string name="cd_show_whole_globe">முழு உலக உருண்டையைக் காட்டு</string>
<string name="cd_globe_activity">%1$d செயலில் உள்ள அரட்டைகள், %2$d பேர் ஆன்லைனில்</string>
<string name="cd_channel_activity">%1$d பேர், கடந்த ஒரு மணி நேரத்தில் %2$d செய்திகள்</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">รายการโปรด (ออฟไลน์)</string>
<string name="cd_decrease_precision">ลดความแม่นยำ</string>
<string name="cd_increase_precision">เพิ่มความแม่นยำ</string>
<string name="cd_select_geohash">เลือก geohash</string>
<string name="cd_scroll_to_bottom">เลื่อนไปด้านล่างสุด</string>
<string name="cd_file">ไฟล์</string>
<string name="cd_image">รูปภาพ</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat ไม่ติดตามตำแหน่งของคุณ</string>
<string name="nobody_around">ไม่มีใครอยู่ใกล้…</string>
<string name="you_suffix"> (คุณ)</string>
<string name="pan_zoom_instruction">ลาก/ซูมเพื่อเลือก geohash</string>
<string name="select">เลือก</string>
<string name="type_a_message_placeholder">พิมพ์ข้อความ…</string>
<string name="mention">กล่าวถึง</string>
<string name="version_prefix">v%1$s</string>
@ -382,4 +379,14 @@
<string name="about_select_language">เลือกภาษา</string>
<string name="about_spam_filter_title">ตัวกรองสแปม</string>
<string name="about_spam_filter_desc">ซ่อนข้อความสาธารณะจากผู้ที่ส่งข้อความเร็วเกินไป ส่งข้อความซ้ำ หรือส่งลิงก์จำนวนมากเกินไป</string>
<string name="globe_live">สด</string>
<string name="globe_connecting">กำลังเชื่อมต่อ</string>
<string name="globe_offline">ออฟไลน์</string>
<string name="globe_hint_drag">หมุน</string>
<string name="globe_hint_pinch">ซูม</string>
<string name="globe_hint_tap">แตะที่จุด</string>
<string name="globe_no_activity">ไม่มีกิจกรรมล่าสุด</string>
<string name="cd_show_whole_globe">แสดงลูกโลกทั้งใบ</string>
<string name="cd_globe_activity">แชทที่ใช้งานอยู่ %1$d รายการ ออนไลน์ %2$d คน</string>
<string name="cd_channel_activity">%1$d คน ข้อความ %2$d รายการในชั่วโมงที่ผ่านมา</string>
</resources>

View File

@ -134,7 +134,6 @@
<string name="cd_offline_favorite">Favori (çevrimdışı)</string>
<string name="cd_decrease_precision">Hassasiyeti azalt</string>
<string name="cd_increase_precision">Hassasiyeti artır</string>
<string name="cd_select_geohash">Geohash seç</string>
<string name="cd_scroll_to_bottom">Alta kaydır</string>
<string name="cd_file">Dosya</string>
<string name="cd_image">Görsel</string>
@ -259,8 +258,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">yakında kimse yok…</string>
<string name="you_suffix"> (sen)</string>
<string name="pan_zoom_instruction">geohash seçmek için kaydır/zoom yap</string>
<string name="select">seç</string>
<string name="type_a_message_placeholder">mesaj yaz…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">bahset</string>
@ -380,4 +377,14 @@
<string name="about_select_language">Dil seçin</string>
<string name="about_spam_filter_title">Spam filtresi</string>
<string name="about_spam_filter_desc">Çok hızlı mesaj gönderen, kendini tekrarlayan veya çok fazla bağlantı paylaşan kişilerin herkese açık mesajlarını gizle.</string>
<string name="globe_live">canlı</string>
<string name="globe_connecting">bağlanıyor</string>
<string name="globe_offline">çevrimdışı</string>
<string name="globe_hint_drag">döndür</string>
<string name="globe_hint_pinch">yakınlaştır</string>
<string name="globe_hint_tap">bir noktaya dokun</string>
<string name="globe_no_activity">yakın zamanda etkinlik yok</string>
<string name="cd_show_whole_globe">Tüm küreyi göster</string>
<string name="cd_globe_activity">%1$d etkin sohbet, %2$d kişi çevrimiçi</string>
<string name="cd_channel_activity">%1$d kişi, son bir saatte %2$d mesaj</string>
</resources>

View File

@ -181,7 +181,6 @@
<string name="cd_offline_favorite">Офлайн обране</string>
<string name="cd_decrease_precision">Зменшити точність</string>
<string name="cd_increase_precision">Збільшити точність</string>
<string name="cd_select_geohash">Вибрати геохеш</string>
<string name="cd_scroll_to_bottom">Прогорнути донизу</string>
<string name="cd_file">Файл</string>
<string name="cd_image">Зображення</string>
@ -325,8 +324,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">нікого немає поруч...</string>
<string name="you_suffix"> (ви)</string>
<string name="pan_zoom_instruction">панорамуйте та масштабуйте, щоб вибрати геохеш</string>
<string name="select">вибрати</string>
<string name="type_a_message_placeholder">введіть повідомлення...</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">згадка</string>
@ -402,4 +399,14 @@
<string name="about_select_language">Вибрати мову</string>
<string name="about_spam_filter_title">Спам-фільтр</string>
<string name="about_spam_filter_desc">Приховувати публічні повідомлення від тих, хто пише надто швидко, повторюється або надсилає забагато посилань.</string>
<string name="globe_live">наживо</string>
<string name="globe_connecting">підключення</string>
<string name="globe_offline">офлайн</string>
<string name="globe_hint_drag">обертати</string>
<string name="globe_hint_pinch">масштаб</string>
<string name="globe_hint_tap">торкніться точки</string>
<string name="globe_no_activity">немає нещодавньої активності</string>
<string name="cd_show_whole_globe">Показати весь глобус</string>
<string name="cd_globe_activity">Активних чатів: %1$d, людей онлайн: %2$d</string>
<string name="cd_channel_activity">Людей: %1$d, повідомлень за останню годину: %2$d</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">پسندیدہ (آف لائن)</string>
<string name="cd_decrease_precision">درستگی کم کریں</string>
<string name="cd_increase_precision">درستگی بڑھائیں</string>
<string name="cd_select_geohash">geohash منتخب کریں</string>
<string name="cd_scroll_to_bottom">نیچے اسکرول کریں</string>
<string name="cd_file">فائل</string>
<string name="cd_image">تصویر</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat آپ کا مقام ٹریک نہیں کرتا</string>
<string name="nobody_around">یہاں کوئی نہیں…</string>
<string name="you_suffix"> (آپ)</string>
<string name="pan_zoom_instruction">geohash منتخب کرنے کے لیے پین/زوم کریں</string>
<string name="select">منتخب کریں</string>
<string name="type_a_message_placeholder">پیغام لکھیں…</string>
<string name="mention">ذکر</string>
<string name="version_prefix">v%1$s</string>
@ -395,4 +392,14 @@
<string name="about_select_language">زبان منتخب کریں</string>
<string name="about_spam_filter_title">اسپام فلٹر</string>
<string name="about_spam_filter_desc">ایسے لوگوں کے عوامی پیغامات چھپائیں جو بہت تیزی سے پیغامات بھیجتے ہیں، اپنی بات دہراتے ہیں یا بہت زیادہ لنکس بھیجتے ہیں۔</string>
<string name="globe_live">لائیو</string>
<string name="globe_connecting">جڑ رہا ہے</string>
<string name="globe_offline">آف لائن</string>
<string name="globe_hint_drag">گھمائیں</string>
<string name="globe_hint_pinch">زوم</string>
<string name="globe_hint_tap">کسی نقطے پر ٹیپ کریں</string>
<string name="globe_no_activity">کوئی حالیہ سرگرمی نہیں</string>
<string name="cd_show_whole_globe">پورا گلوب دکھائیں</string>
<string name="cd_globe_activity">%1$d فعال چیٹس، %2$d لوگ آن لائن</string>
<string name="cd_channel_activity">%1$d لوگ، پچھلے گھنٹے میں %2$d پیغامات</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">Yêu thích (ngoại tuyến)</string>
<string name="cd_decrease_precision">Giảm độ chính xác</string>
<string name="cd_increase_precision">Tăng độ chính xác</string>
<string name="cd_select_geohash">Chọn geohash</string>
<string name="cd_scroll_to_bottom">Cuộn xuống dưới</string>
<string name="cd_file">File</string>
<string name="cd_image">Hình ảnh</string>
@ -280,8 +279,6 @@
<string name="location_tracking_warning">bitchat KHÔNG theo dõi vị trí của bạn</string>
<string name="nobody_around">không có ai xung quanh …</string>
<string name="you_suffix"> (bạn)</string>
<string name="pan_zoom_instruction">di chuyển và zoom để chọn geohash</string>
<string name="select">chọn</string>
<string name="type_a_message_placeholder">Nhập tin nhắn …</string>
<string name="mention">nhắc đến</string>
<string name="version_prefix">v%1$s</string>
@ -382,4 +379,14 @@
<string name="about_select_language">Chọn ngôn ngữ</string>
<string name="about_spam_filter_title">Bộ lọc thư rác</string>
<string name="about_spam_filter_desc">Ẩn tin nhắn công khai từ những người gửi quá nhanh, lặp lại nội dung hoặc gửi quá nhiều liên kết.</string>
<string name="globe_live">trực tiếp</string>
<string name="globe_connecting">đang kết nối</string>
<string name="globe_offline">ngoại tuyến</string>
<string name="globe_hint_drag">xoay</string>
<string name="globe_hint_pinch">thu phóng</string>
<string name="globe_hint_tap">chạm vào một chấm</string>
<string name="globe_no_activity">không có hoạt động gần đây</string>
<string name="cd_show_whole_globe">Hiển thị toàn bộ địa cầu</string>
<string name="cd_globe_activity">%1$d cuộc trò chuyện đang hoạt động, %2$d người trực tuyến</string>
<string name="cd_channel_activity">%1$d người, %2$d tin nhắn trong giờ qua</string>
</resources>

View File

@ -196,7 +196,6 @@
<string name="cd_offline_favorite">离线收藏</string>
<string name="cd_decrease_precision">降低精度</string>
<string name="cd_increase_precision">提高精度</string>
<string name="cd_select_geohash">选择 Geohash</string>
<string name="cd_scroll_to_bottom">滚动到底部</string>
<string name="cd_file">文件</string>
<string name="cd_image">图片</string>
@ -331,8 +330,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">附近没有人…</string>
<string name="you_suffix"> (你)</string>
<string name="pan_zoom_instruction">拖动并缩放以选择一个 geohash</string>
<string name="select">选择</string>
<string name="type_a_message_placeholder">输入消息…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">提及</string>
@ -390,4 +387,14 @@
<string name="about_select_language">选择语言</string>
<string name="about_spam_filter_title">垃圾消息过滤器</string>
<string name="about_spam_filter_desc">隐藏发送过快、重复发言或发送过多链接的用户的公开消息。</string>
<string name="globe_live">实时</string>
<string name="globe_connecting">连接中</string>
<string name="globe_offline">离线</string>
<string name="globe_hint_drag">旋转</string>
<string name="globe_hint_pinch">缩放</string>
<string name="globe_hint_tap">点按圆点</string>
<string name="globe_no_activity">最近没有活动</string>
<string name="cd_show_whole_globe">显示整个地球</string>
<string name="cd_globe_activity">%1$d 个活跃聊天,%2$d 人在线</string>
<string name="cd_channel_activity">%1$d 人,过去一小时 %2$d 条消息</string>
</resources>

View File

@ -196,7 +196,6 @@
<string name="cd_offline_favorite">離線最愛</string>
<string name="cd_decrease_precision">降低精確度</string>
<string name="cd_increase_precision">提高精確度</string>
<string name="cd_select_geohash">選擇地理雜湊</string>
<string name="cd_scroll_to_bottom">捲動到底部</string>
<string name="cd_file">檔案</string>
<string name="cd_image">圖片</string>
@ -331,8 +330,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">附近沒有人…</string>
<string name="you_suffix"> (你)</string>
<string name="pan_zoom_instruction">平移並縮放以選擇地理雜湊</string>
<string name="select">選擇</string>
<string name="type_a_message_placeholder">輸入訊息…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">提及</string>
@ -391,4 +388,14 @@
<string name="about_select_language">選擇語言</string>
<string name="about_spam_filter_title">垃圾訊息篩選器</string>
<string name="about_spam_filter_desc">隱藏傳送過快、重複發言或傳送過多連結的使用者的公開訊息。</string>
<string name="globe_live">即時</string>
<string name="globe_connecting">連線中</string>
<string name="globe_offline">離線</string>
<string name="globe_hint_drag">旋轉</string>
<string name="globe_hint_pinch">縮放</string>
<string name="globe_hint_tap">點一下圓點</string>
<string name="globe_no_activity">最近沒有活動</string>
<string name="cd_show_whole_globe">顯示整個地球</string>
<string name="cd_globe_activity">%1$d 個活躍聊天,%2$d 人在線上</string>
<string name="cd_channel_activity">%1$d 人,過去一小時 %2$d 則訊息</string>
</resources>

View File

@ -145,7 +145,6 @@
<string name="cd_offline_favorite">收藏(离线)</string>
<string name="cd_decrease_precision">降低精度</string>
<string name="cd_increase_precision">提高精度</string>
<string name="cd_select_geohash">选择 geohash</string>
<string name="cd_scroll_to_bottom">滚动到底部</string>
<string name="cd_file">文件</string>
<string name="cd_image">图片</string>
@ -275,8 +274,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">附近无人…</string>
<string name="you_suffix"> (你)</string>
<string name="pan_zoom_instruction">拖动/缩放以选择 geohash</string>
<string name="select">选择</string>
<string name="type_a_message_placeholder">输入消息…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">提及</string>
@ -406,4 +403,14 @@
<string name="about_select_language">选择语言</string>
<string name="about_spam_filter_title">垃圾消息过滤器</string>
<string name="about_spam_filter_desc">隐藏发送过快、重复发言或发送过多链接的用户的公开消息。</string>
<string name="globe_live">实时</string>
<string name="globe_connecting">连接中</string>
<string name="globe_offline">离线</string>
<string name="globe_hint_drag">旋转</string>
<string name="globe_hint_pinch">缩放</string>
<string name="globe_hint_tap">点按圆点</string>
<string name="globe_no_activity">最近没有活动</string>
<string name="cd_show_whole_globe">显示整个地球</string>
<string name="cd_globe_activity">%1$d 个活跃聊天,%2$d 人在线</string>
<string name="cd_channel_activity">%1$d 人,过去一小时 %2$d 条消息</string>
</resources>

View File

@ -362,7 +362,6 @@
<string name="cd_offline_favorite">Offline favorite</string>
<string name="cd_decrease_precision">Decrease precision</string>
<string name="cd_increase_precision">Increase precision</string>
<string name="cd_select_geohash">Select geohash</string>
<string name="cd_scroll_to_bottom">Scroll to bottom</string>
<string name="cd_file">File</string>
<string name="cd_image">Image</string>
@ -584,8 +583,6 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">Nobody around…</string>
<string name="you_suffix"> (you)</string>
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
<string name="select">Select</string>
<string name="type_a_message_placeholder">Type a message…</string>
<string name="mention_suggestion_at">@%1$s</string>
<string name="mention">Mention</string>
@ -663,4 +660,14 @@
<string name="about_app_language">App language</string>
<string name="about_system_default">System default</string>
<string name="about_select_language">Select language</string>
<string name="globe_live">live</string>
<string name="globe_connecting">connecting</string>
<string name="globe_offline">offline</string>
<string name="globe_hint_drag">spin</string>
<string name="globe_hint_pinch">zoom</string>
<string name="globe_hint_tap">tap a dot</string>
<string name="globe_no_activity">no recent activity</string>
<string name="cd_show_whole_globe">Show the whole globe</string>
<string name="cd_globe_activity">%1$d active chats, %2$d people online</string>
<string name="cd_channel_activity">%1$d people, %2$d messages in the last hour</string>
</resources>

View File

@ -0,0 +1,134 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GeohashActivityAggregatorTest {
private val now = 1_800_000_000L
private val aggregator = GeohashActivityAggregator()
private fun chat(id: String, pubkey: String, geohash: String = "u0", at: Long = now, content: String = "hi") =
aggregator.record(NostrKind.EPHEMERAL_EVENT, id, pubkey, geohash, at, content, now)
private fun heartbeat(id: String, pubkey: String, geohash: String = "u0", at: Long = now) =
aggregator.record(NostrKind.GEOHASH_PRESENCE, id, pubkey, geohash, at, "", now)
@Test
fun `chat and heartbeats fold into one channel with distinct people`() {
chat("e1", "alice")
chat("e2", "alice")
chat("e3", "bob")
heartbeat("e4", "carol")
heartbeat("e5", "alice")
val channel = aggregator.snapshot(now).channel("u0")!!
assertEquals(3, channel.people)
assertEquals(2, channel.chatters)
assertEquals(3, channel.messages)
assertEquals(3, channel.online)
}
@Test
fun `duplicate event ids count once`() {
assertTrue(chat("e1", "alice"))
assertFalse(chat("e1", "alice"))
assertEquals(1, aggregator.snapshot(now).channel("u0")!!.messages)
}
@Test
fun `heartbeats expire after five minutes but messages last an hour`() {
heartbeat("e1", "carol", at = now - 301)
chat("e2", "alice", at = now - 3_000)
val channel = aggregator.snapshot(now).channel("u0")!!
assertEquals(1, channel.people)
assertEquals(0, channel.online)
assertEquals(1, channel.messages)
}
@Test
fun `events outside their window or from the future are ignored`() {
assertFalse(heartbeat("e1", "carol", at = now - 600))
assertFalse(chat("e2", "alice", at = now - 4_000))
assertFalse(chat("e3", "bob", at = now + 3_600))
assertTrue(aggregator.snapshot(now).channels.isEmpty())
}
@Test
fun `empty chat events count as presence not messages`() {
chat("e1", "alice", content = " ")
val channel = aggregator.snapshot(now).channel("u0")!!
assertEquals(0, channel.messages)
assertEquals(1, channel.online)
}
@Test
fun `invalid geohashes are rejected and valid ones normalized`() {
assertFalse(chat("e1", "alice", geohash = "u0a!"))
assertFalse(chat("e2", "alice", geohash = "ailo"))
assertTrue(chat("e3", "alice", geohash = "U0BC"))
assertEquals(listOf("u0bc"), aggregator.snapshot(now).channels.map { it.geohash })
}
@Test
fun `prune drops channels whose activity has left every window`() {
heartbeat("e1", "carol", at = now - 200)
assertTrue(aggregator.prune(now + 200))
assertNull(aggregator.snapshot(now + 200).channel("u0"))
}
@Test
fun `wouldGrow is true only for new people or new messages`() {
assertTrue(aggregator.wouldGrow("u0", "carol", isChat = false, eventId = "e1"))
heartbeat("e1", "carol")
assertFalse(aggregator.wouldGrow("u0", "carol", isChat = false, eventId = "e2"))
assertTrue(aggregator.wouldGrow("u0", "carol", isChat = true, eventId = "e3"))
assertTrue(aggregator.wouldGrow("u1", "carol", isChat = false, eventId = "e4"))
}
@Test
fun `channels are ordered busiest first`() {
heartbeat("e1", "a", geohash = "9q")
heartbeat("e2", "a", geohash = "u0")
heartbeat("e3", "b", geohash = "u0")
assertEquals(listOf("u0", "9q"), aggregator.snapshot(now).channels.map { it.geohash })
}
@Test
fun `nested heartbeat levels are not double counted online`() {
// One person heartbeats region, province and city channels under three identities.
heartbeat("e1", "id-region", geohash = "u0")
heartbeat("e2", "id-province", geohash = "u0bc")
heartbeat("e3", "id-city", geohash = "u0bcd")
heartbeat("e4", "other-city", geohash = "u0bce")
assertEquals(2, aggregator.snapshot(now).totalOnline)
}
@Test
fun `distinct people estimate takes the busiest precision level`() {
fun channel(geohash: String, people: Int) =
GeohashChannelActivity(geohash, 0.0, 0.0, people, 0, 0, people, now)
val estimate = estimateDistinctPeople(
listOf(channel("u0", 5), channel("u0bc", 3), channel("u0bd", 4), channel("u0bcd", 6))
)
assertEquals(7, estimate)
assertEquals(0, estimateDistinctPeople(emptyList()))
}
}

View File

@ -0,0 +1,80 @@
package com.bitchat.android.ui.globe
import androidx.compose.ui.geometry.Offset
import com.bitchat.android.nostr.GeohashChannelActivity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeActivityLayerTest {
private fun channel(geohash: String, lat: Double, lon: Double, people: Int, messages: Int = 0) =
GeohashChannelActivity(geohash, lat, lon, people, 0, messages, people, 0L)
private fun layout(channels: List<GeohashChannelActivity>, zoomRadius: Float = 400f) =
layoutGlobeMarkers(
channels = channels,
projector = GlobeMath.PreparedProjector(0.0, 0.0),
cx = 500f,
cy = 500f,
r = zoomRadius,
width = 1000f,
height = 1000f,
density = 1f
)
@Test
fun overlappingChannels_mergeIntoOneCluster() {
val markers = layout(listOf(channel("s00", 0.0, 0.0, 9, 4), channel("s01", 0.2, 0.2, 1)))
val cluster = markers.single()
assertTrue(cluster.isCluster)
assertEquals("s00", cluster.primary.geohash)
assertEquals(4, cluster.messages)
}
@Test
fun clusterSplits_whenZoomedIn() {
val channels = listOf(channel("s00", 0.0, 0.0, 9), channel("s01", 0.2, 0.2, 1))
assertEquals(2, layout(channels, zoomRadius = 40_000f).size)
}
@Test
fun farSideChannels_areNotDrawn() {
assertTrue(layout(listOf(channel("8", 0.0, 180.0, 50))).isEmpty())
}
@Test
fun hitTest_picksMarkerUnderFingerWithinSlop() {
val markers = layout(listOf(channel("s00", 0.0, 0.0, 4), channel("t00", 0.0, 40.0, 4)))
val markerLayout = GlobeMarkerLayout().apply { this.markers = markers }
assertSame(markers.first { it.primary.geohash == "s00" }, markerLayout.hitTest(Offset(503f, 500f), 8f))
assertNull(markerLayout.hitTest(Offset(500f, 900f), 8f))
}
@Test
fun chatMarkers_outgrowHeartbeatOnlyMarkers() {
assertTrue(markerRadiusDp(4, chatting = true, cluster = false) > markerRadiusDp(4, chatting = false, cluster = false))
assertTrue(markerRadiusDp(400, chatting = true, cluster = false) <= 17f)
}
@Test
fun heat_risesWithMessagesAndSaturates() {
assertEquals(0f, markerHeat(0))
assertTrue(markerHeat(5) in 0f..1f)
assertEquals(1f, markerHeat(10_000))
}
@Test
fun compactCount_shortensLargeNumbers() {
assertEquals("7", compactCount(7))
assertEquals("842", compactCount(842))
assertEquals("1.2k", compactCount(1_234))
assertEquals("2k", compactCount(2_010))
assertEquals("12k", compactCount(12_400))
}
}