From b56ed5bb79c4959fb3da72a879b9efdd09345ecf Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:11:15 +0100 Subject: [PATCH] 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. --- .../bitchat/android/nostr/GeohashActivity.kt | 213 +++++ .../android/nostr/GeohashActivityMonitor.kt | 209 +++++ .../android/nostr/NostrRelayManager.kt | 58 +- .../android/ui/GeohashPickerActivity.kt | 777 +++++++++++++++--- .../android/ui/globe/GlobeActivityLayer.kt | 308 +++++++ .../com/bitchat/android/ui/globe/GlobeView.kt | 72 +- app/src/main/res/values-ar/strings.xml | 13 +- app/src/main/res/values-bn/strings.xml | 13 +- app/src/main/res/values-de/strings.xml | 13 +- app/src/main/res/values-es/strings.xml | 13 +- app/src/main/res/values-eu/strings.xml | 13 +- app/src/main/res/values-fa/strings.xml | 13 +- app/src/main/res/values-fil/strings.xml | 13 +- app/src/main/res/values-fr/strings.xml | 13 +- app/src/main/res/values-he/strings.xml | 13 +- app/src/main/res/values-hi/strings.xml | 13 +- app/src/main/res/values-id/strings.xml | 13 +- app/src/main/res/values-it/strings.xml | 13 +- app/src/main/res/values-ja/strings.xml | 13 +- app/src/main/res/values-ka/strings.xml | 13 +- app/src/main/res/values-ko/strings.xml | 13 +- app/src/main/res/values-mg/strings.xml | 13 +- app/src/main/res/values-ms/strings.xml | 13 +- app/src/main/res/values-ne/strings.xml | 13 +- app/src/main/res/values-nl/strings.xml | 13 +- app/src/main/res/values-pa-rPK/strings.xml | 13 +- app/src/main/res/values-pl/strings.xml | 13 +- app/src/main/res/values-pt-rBR/strings.xml | 13 +- app/src/main/res/values-pt/strings.xml | 13 +- app/src/main/res/values-ru/strings.xml | 13 +- app/src/main/res/values-sv/strings.xml | 13 +- app/src/main/res/values-ta/strings.xml | 13 +- app/src/main/res/values-th/strings.xml | 13 +- app/src/main/res/values-tr/strings.xml | 13 +- app/src/main/res/values-uk/strings.xml | 13 +- app/src/main/res/values-ur/strings.xml | 13 +- app/src/main/res/values-vi/strings.xml | 13 +- app/src/main/res/values-zh-rCN/strings.xml | 13 +- app/src/main/res/values-zh-rTW/strings.xml | 13 +- app/src/main/res/values-zh/strings.xml | 13 +- app/src/main/res/values/strings.xml | 13 +- .../nostr/GeohashActivityAggregatorTest.kt | 134 +++ .../ui/globe/GlobeActivityLayerTest.kt | 80 ++ 43 files changed, 2091 insertions(+), 215 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/GeohashActivity.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/GeohashActivityMonitor.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/globe/GlobeActivityLayer.kt create mode 100644 app/src/test/java/com/bitchat/android/nostr/GeohashActivityAggregatorTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeActivityLayerTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashActivity.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashActivity.kt new file mode 100644 index 000000000..671408639 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashActivity.kt @@ -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, + /** 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 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, + 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() + /** event id -> message, for chat events only. */ + val messages = LinkedHashMap() + var lastActiveAt = 0L + } + + private val channels = HashMap() + + /** + * 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(channels.size) + for ((geohash, channel) in channels) { + val chatters = HashSet() + 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(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 { 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" + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashActivityMonitor.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashActivityMonitor.kt new file mode 100644 index 000000000..4c99ae37b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashActivityMonitor.kt @@ -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 = _snapshot.asStateFlow() + + private val _status = MutableStateFlow(Status.CONNECTING) + val status: StateFlow = _status.asStateFlow() + + private var scope: CoroutineScope? = null + private var inbox: Channel? = null + private val subscriptionIds = mutableListOf() + private val subscribedRelays = mutableSetOf() + private val openedRelays = mutableSetOf() + 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(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 { + 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 { + 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) { + 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 + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 18f4952a4..5b294825c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -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) { + 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) { + 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? = 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 diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index ce450ded8..62254ef37 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -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?>(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 { 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, + 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) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeActivityLayer.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeActivityLayer.kt new file mode 100644 index 000000000..c5e738472 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeActivityLayer.kt @@ -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, + /** 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 + 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 = 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, + projector: GlobeMath.PreparedProjector, + cx: Float, + cy: Float, + r: Float, + width: Float, + height: Float, + density: Float +): List { + 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() + 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, + 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 diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt index 7066cf924..e0c39d292 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt @@ -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, labelTypeface: Typeface?, labelTypefaceBold: Typeface?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + activity: List = 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 diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4b4d1fa77..5bc20e6f3 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -145,7 +145,6 @@ مفضل (غير متصل) خفض الدقة رفع الدقة - اختر geohash التمرير إلى الأسفل ملف صورة @@ -280,8 +279,6 @@ bitchat لا يتتبع موقعك لا أحد هنا… (أنت) - حرّك وقرّب لاختيار geohash - اختيار اكتب رسالة… إشارة v%1$s @@ -395,4 +392,14 @@ اختر اللغة فلتر الرسائل المزعجة إخفاء الرسائل العامة من الأشخاص الذين يرسلون بسرعة مفرطة أو يكررون رسائلهم أو يغرقون الدردشة بالروابط. + مباشر + جارٍ الاتصال + غير متصل + تدوير + تكبير + اضغط على نقطة + لا يوجد نشاط حديث + عرض الكرة الأرضية كاملة + %1$d محادثات نشطة، %2$d أشخاص متصلون + %1$d أشخاص، %2$d رسائل في الساعة الأخيرة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 746ca763f..957a348d2 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -145,7 +145,6 @@ প্রিয় (অফলাইন) নির্ভুলতা কমান নির্ভুলতা বাড়ান - জিওহ্যাশ নির্বাচন করুন নিচে স্ক্রল করুন ফাইল ছবি @@ -280,8 +279,6 @@ bitchat আপনার অবস্থান ট্র্যাক করে না কেউ আশেপাশে নেই … (আপনি) - জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন - নির্বাচন করুন বার্তা টাইপ করুন … উল্লেখ v%1$s @@ -382,4 +379,14 @@ ভাষা নির্বাচন করুন স্প্যাম ফিল্টার যারা খুব দ্রুত বার্তা পাঠান, একই কথা বারবার বলেন বা অতিরিক্ত লিঙ্ক পাঠান, তাঁদের প্রকাশ্য বার্তা লুকান। + লাইভ + সংযুক্ত হচ্ছে + অফলাইন + ঘোরান + জুম + একটি বিন্দুতে ট্যাপ করুন + সাম্প্রতিক কোনো কার্যকলাপ নেই + পুরো গ্লোব দেখান + %1$dটি সক্রিয় চ্যাট, %2$d জন অনলাইনে + %1$d জন, গত এক ঘণ্টায় %2$dটি বার্তা diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fe7933126..8a968d144 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -145,7 +145,6 @@ Favorit (offline) Genauigkeit verringern Genauigkeit erhöhen - Geohash auswählen Nach unten scrollen Datei Bild @@ -280,8 +279,6 @@ bitchat verfolgt NICHT deinen Standort niemand da … (du) - schwenken und zoomen, um einen Geohash auszuwählen - auswählen Nachricht eingeben … erwähnen v%1$s @@ -396,4 +393,14 @@ Sprache auswählen Spamfilter Öffentliche Nachrichten von Personen ausblenden, die zu schnell schreiben, sich wiederholen oder zu viele Links senden. + live + verbinde + offline + drehen + zoomen + Punkt antippen + keine aktuelle Aktivität + Ganzen Globus anzeigen + %1$d aktive Chats, %2$d Personen online + %1$d Personen, %2$d Nachrichten in der letzten Stunde diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e427714ff..21f87f8dc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -145,7 +145,6 @@ Favorito (sin conexión) Disminuir precisión Aumentar precisión - Seleccionar geohash Desplazar al final Archivo Imagen @@ -280,8 +279,6 @@ bitchat NO rastrea tu ubicación nadie por aquí… (tú) - desplaza y hace zoom para seleccionar un geohash - seleccionar Escribe un mensaje… mencionar v%1$s @@ -395,4 +392,14 @@ Seleccionar idioma Filtro de spam Oculta los mensajes públicos de quienes publican demasiado rápido, se repiten o envían demasiados enlaces. + en vivo + conectando + sin conexión + girar + zoom + toca un punto + sin actividad reciente + Mostrar todo el globo + %1$d chats activos, %2$d personas en línea + %1$d personas, %2$d mensajes en la última hora diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 7e9c21894..16ee32bd2 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -342,7 +342,6 @@ Lineaz kanpoko gogokoa Txikitu zehaztasuna Handitu zehaztasuna - Hautatu geohash-a Joan behera Fitxategia Irudia @@ -556,8 +555,6 @@ Ireki Honi buruz Ez dago inor inguruan… (zu) - Arrastatu biratzeko · Atximurkatu zoom egiteko · Sakatu fokatzeko - Hautatu Idatzi mezu bat… Aipatu Irudia @@ -625,4 +622,14 @@ image/* Spam-iragazkia Ezkutatu azkarregi idazten duten, mezuak errepikatzen dituzten edo esteka gehiegi bidaltzen dituzten pertsonen mezu publikoak. + zuzenean + konektatzen + lineaz kanpo + biratu + zooma + sakatu puntu bat + ez dago azken jarduerarik + Erakutsi globo osoa + %1$d txat aktibo, %2$d pertsona konektatuta + %1$d pertsona, %2$d mezu azken orduan diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index fa86d3d41..0a13ed130 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -145,7 +145,6 @@ دلخواه (آفلاین) کاهش دقت افزایش دقت - انتخاب geohash اسکرول به پایین فایل تصویر @@ -280,8 +279,6 @@ bitchat موقعیت شما را پیگیری نمی‌کند کسی در اطراف نیست … (شما) - برای انتخاب geohash، جابه‌جا و زوم کنید - انتخاب نوشتن پیام … ذکر v%1$s @@ -382,4 +379,14 @@ انتخاب زبان فیلتر هرزنامه پیام‌های عمومی افرادی را که خیلی سریع پیام می‌فرستند، خود را تکرار می‌کنند یا پیوندهای زیادی می‌فرستند پنهان کن. + زنده + در حال اتصال + آفلاین + چرخش + بزرگ‌نمایی + روی یک نقطه بزنید + فعالیت اخیری نیست + نمایش کل کره زمین + %1$d گفتگوی فعال، %2$d نفر آنلاین + %1$d نفر، %2$d پیام در ساعت گذشته diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 283079381..f7df0f3ae 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -145,7 +145,6 @@ Paborito (offline) Bawasan ang precision Dagdagan ang precision - Piliin ang geohash Mag‑scroll pababa File Larawan @@ -275,8 +274,6 @@ · ⧉ walang tao sa paligid… (ikaw) - i‑pan at i‑zoom para pumili ng geohash - piliin magtype ng mensahe… @%1$s banggit @@ -393,4 +390,14 @@ Pumili ng wika Filter ng spam Itago ang mga pampublikong mensahe mula sa mga taong masyadong mabilis mag-post, paulit-ulit ang sinasabi, o nagpapadala ng napakaraming link. + live + kumokonekta + offline + iikot + zoom + i-tap ang tuldok + walang kamakailang aktibidad + Ipakita ang buong globo + %1$d aktibong chat, %2$d tao ang online + %1$d tao, %2$d mensahe sa nakaraang oras diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4f5b0d287..4721d401b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -145,7 +145,6 @@ Favori (hors‑ligne) Diminuer la précision Augmenter la précision - Sélectionner le geohash Aller en bas Fichier Image @@ -276,8 +275,6 @@ · ⧉ personne aux alentours… (toi) - déplacer et zoomer pour choisir un geohash - sélectionner écrire un message… @%1$s mention @@ -407,4 +404,14 @@ Choisir la langue Filtre antispam Masquer les messages publics des personnes qui publient trop vite, se répètent ou inondent le chat de liens. + en direct + connexion + hors ligne + tourner + zoomer + touchez un point + aucune activité récente + Afficher tout le globe + %1$d discussions actives, %2$d personnes en ligne + %1$d personnes, %2$d messages au cours de la dernière heure diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index da4fd7fde..1fb1612dd 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -198,7 +198,6 @@ מועדף לא מקוון הפחת דיוק הגבר דיוק - בחר geohash גלול לתחתית קובץ תמונה @@ -333,8 +332,6 @@ · ⧉ אין אף אחד בסביבה... (אתה) - הזז ושנה תצוגה כדי לבחור geohash - בחר הקלד הודעה... @%1$s אזכור @@ -394,4 +391,14 @@ בחירת שפה מסנן ספאם הסתר הודעות ציבוריות מאנשים ששולחים מהר מדי, חוזרים על עצמם או מציפים בקישורים. + חי + מתחבר + לא מחובר + סיבוב + זום + הקש על נקודה + אין פעילות אחרונה + הצג את כל הגלובוס + %1$d צ׳אטים פעילים, %2$d אנשים מחוברים + %1$d אנשים, %2$d הודעות בשעה האחרונה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 42a5db6b6..b1b9bf0fb 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -145,7 +145,6 @@ पसंदीदा (ऑफ़लाइन) सटीकता घटाएँ सटीकता बढ़ाएँ - geohash चुनें नीचे स्क्रॉल करें फ़ाइल छवि @@ -280,8 +279,6 @@ bitchat आपकी लोकेशन ट्रैक नहीं करती आस‑पास कोई नहीं… (आप) - geohash चुनने के लिए पैन/ज़ूम करें - चयन करें संदेश लिखें… उल्लेख v%1$s @@ -395,4 +392,14 @@ भाषा चुनें स्पैम फ़िल्टर बहुत तेज़ी से संदेश भेजने, एक ही बात दोहराने या बहुत अधिक लिंक भेजने वाले लोगों के सार्वजनिक संदेश छिपाएँ। + लाइव + कनेक्ट हो रहा है + ऑफ़लाइन + घुमाएँ + ज़ूम + किसी बिंदु पर टैप करें + हाल की कोई गतिविधि नहीं + पूरा ग्लोब दिखाएँ + %1$d सक्रिय चैट, %2$d लोग ऑनलाइन + %1$d लोग, पिछले घंटे में %2$d संदेश diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 48f1056bf..48d26e5e2 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -145,7 +145,6 @@ Favorit (offline) Kurangi presisi Tingkatkan presisi - Pilih geohash Gulir ke bawah File Gambar @@ -280,8 +279,6 @@ bitchat TIDAK melacak lokasi Anda tidak ada orang di sekitar … (Anda) - geser dan zoom untuk memilih geohash - pilih Ketik pesan … sebut v%1$s @@ -395,4 +392,14 @@ Pilih bahasa Filter spam Sembunyikan pesan publik dari orang yang mengirim terlalu cepat, mengulang pesan, atau membanjiri obrolan dengan tautan. + langsung + menghubungkan + offline + putar + zoom + ketuk titik + tidak ada aktivitas terbaru + Tampilkan seluruh globe + %1$d obrolan aktif, %2$d orang online + %1$d orang, %2$d pesan dalam satu jam terakhir diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 489013089..fddbaa0b5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -145,7 +145,6 @@ Preferito offline Diminuisci precisione Aumenta precisione - Seleziona geohash Scorri in fondo File Immagine @@ -280,8 +279,6 @@ bitchat NON traccia la tua posizione nessuno nei dintorni… (tu) - scorri e ingrandisci per selezionare un geohash - seleziona scrivi un messaggio… menzione v%1$s @@ -422,4 +419,14 @@ Seleziona lingua Filtro antispam Nascondi i messaggi pubblici di chi scrive troppo velocemente, si ripete o invia troppi link. + live + connessione + offline + ruota + zoom + tocca un punto + nessuna attività recente + Mostra tutto il globo + %1$d chat attive, %2$d persone online + %1$d persone, %2$d messaggi nell\'ultima ora diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 24c0ab07f..039ea0d00 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -145,7 +145,6 @@ お気に入り (オフライン) 精度を下げる 精度を上げる - Geohash を選択 一番下へスクロール ファイル 画像 @@ -280,8 +279,6 @@ bitchat はあなたの位置情報を追跡しません 誰もいません… (あなた) - パン/ズームして geohash を選択 - 選択 メッセージを入力… メンション v%1$s @@ -395,4 +392,14 @@ 言語を選択 スパムフィルター 短時間に大量のメッセージを送信したり、同じ内容を繰り返したり、リンクを大量に投稿したりする人の公開メッセージを非表示にします。 + ライブ + 接続中 + オフライン + 回転 + ズーム + ドットをタップ + 最近のアクティビティなし + 地球全体を表示 + アクティブなチャット %1$d 件、オンライン %2$d 人 + %1$d 人、過去1時間のメッセージ %2$d 件 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index b62b40465..a19b48c11 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -145,7 +145,6 @@ რჩეული (ოფლაინ) სიზუსტის შემცირება სიზუსტის გაზრდა - აირჩიეთ geohash ქვემოთ გადახვევა ფაილი სურათი @@ -280,8 +279,6 @@ bitchat არ აკონტროლებს თქვენს მდებარეობას არავინ არის ახლოს… (თქვენ) - პანორამირება/ზუმი geohash-ის ასარჩევად - არჩევა შეიყვანეთ შეტყობინება… ხსენება v%1$s @@ -382,4 +379,14 @@ ენის არჩევა სპამის ფილტრი დამალე იმ ადამიანების საჯარო შეტყობინებები, რომლებიც ზედმეტად სწრაფად წერენ, იმეორებენ შეტყობინებებს ან აგზავნიან ზედმეტად ბევრ ბმულს. + ლაივი + უკავშირდება + ხაზგარეშე + ტრიალი + მასშტაბი + შეეხეთ წერტილს + ბოლო აქტივობა არ არის + მთელი გლობუსის ჩვენება + %1$d აქტიური ჩატი, %2$d ადამიანი ონლაინ + %1$d ადამიანი, %2$d შეტყობინება ბოლო საათში diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f1c095258..d2f9f1cbc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -145,7 +145,6 @@ 즐겨찾기 (오프라인) 정밀도 낮추기 정밀도 높이기 - geohash 선택 아래로 스크롤 파일 이미지 @@ -280,8 +279,6 @@ bitchat은 사용자의 위치를 추적하지 않습니다 주변에 아무도 없음 … (나) - geohash를 선택하려면 이동하고 확대/축소하세요 - 선택 메시지 입력 … 멘션 v%1$s @@ -395,4 +392,14 @@ 언어 선택 스팸 필터 너무 빠르게 메시지를 보내거나, 같은 내용을 반복하거나, 링크를 과도하게 보내는 사람의 공개 메시지를 숨깁니다. + 라이브 + 연결 중 + 오프라인 + 회전 + 확대 + 점을 탭하세요 + 최근 활동 없음 + 지구 전체 보기 + 활성 채팅 %1$d개, 온라인 %2$d명 + %1$d명, 지난 1시간 메시지 %2$d개 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 0475af611..813c1b90a 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -145,7 +145,6 @@ Ankafizina tsy an-tserasera Hampihenana ny fahamarinana Hampitombo ny fahamarinana - Hifidy geohash Hidina any ambany Rakitra Sary @@ -284,8 +283,6 @@ · ⧉ tsy misy olona manodidina... (ianao) - ahetsika sy ataovy lehibe mba hifidianana geohash - hifidy soraty hafatra... @%1$s hanonona @@ -402,4 +399,14 @@ Safidio ny fiteny Sivana spam Afeno ny hafatra ampahibemaso avy amin’ireo olona mandefa haingana loatra, mamerimberina hafatra, na mandefa rohy be loatra. + mivantana + mampifandray + tsy mifandray + ahodino + hanalehibe + tsindrio teboka iray + tsy misy hetsika vao haingana + Asehoy ny tany manontolo + resaka mavitrika %1$d, olona %2$d an-tserasera + olona %1$d, hafatra %2$d tao anatin\'ny ora farany diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 74bf035f2..83a67294e 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -158,7 +158,6 @@ Kegemaran luar talian Kurangkan ketepatan Tingkatkan ketepatan - Pilih geohash Tatal ke bawah Fail Imej @@ -356,8 +355,6 @@ · ⧉ tiada sesiapa berdekatan... (anda) - gerak dan zum untuk memilih geohash - pilih taip mesej... @%1$s sebutan @@ -435,4 +432,14 @@ Pilih bahasa Penapis spam Sembunyikan mesej awam daripada orang yang menghantar terlalu pantas, mengulang mesej atau membanjiri perbualan dengan pautan. + langsung + menyambung + luar talian + putar + zum + ketik satu titik + tiada aktiviti terkini + Tunjukkan seluruh glob + %1$d sembang aktif, %2$d orang dalam talian + %1$d orang, %2$d mesej dalam sejam lepas diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 87b2f1e22..0b1f54efe 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -145,7 +145,6 @@ मनपर्ने (अफलाइन) शुद्धता घटाउनुहोस् शुद्धता बढाउनुहोस् - geohash छान्नुहोस् तल स्क्रोल गर्नुहोस् फाइल तस्वीर @@ -275,8 +274,6 @@ · ⧉ वरिपरि कोही छैन… (तपाईं) - geohash छान्न प्यान र जुम गर्नुहोस् - छान्नुहोस् सन्देश टाइप गर्नुहोस्… @%1$s उल्लेख @@ -393,4 +390,14 @@ भाषा छान्नुहोस् स्प्याम फिल्टर धेरै छिटो सन्देश पठाउने, एउटै कुरा दोहोर्‍याउने वा धेरै लिङ्क पठाउने व्यक्तिका सार्वजनिक सन्देशहरू लुकाउनुहोस्। + लाइभ + जडान हुँदै + अफलाइन + घुमाउनुहोस् + जुम + एउटा बिन्दुमा ट्याप गर्नुहोस् + हालको कुनै गतिविधि छैन + पूरै ग्लोब देखाउनुहोस् + %1$d सक्रिय च्याट, %2$d जना अनलाइन + %1$d जना, पछिल्लो एक घण्टामा %2$d सन्देश diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6637e3869..fb2bd9503 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -145,7 +145,6 @@ Offline favoriet Precisie verlagen Precisie verhogen - Geohash selecteren Naar beneden scrollen Bestand Afbeelding @@ -280,8 +279,6 @@ bitchat volgt NIET uw locatie niemand in de buurt… (jij) - pan en zoom om een geohash te selecteren - selecteren typ een bericht… vermelden v%1$s @@ -420,4 +417,14 @@ Taal selecteren Spamfilter Verberg openbare berichten van mensen die te snel posten, zichzelf herhalen of te veel links sturen. + live + verbinden + offline + draaien + zoomen + tik op een stip + geen recente activiteit + Hele wereldbol tonen + %1$d actieve chats, %2$d mensen online + %1$d mensen, %2$d berichten in het afgelopen uur diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3e37dfbf9..75fd85c56 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -145,7 +145,6 @@ پسندیدہ (آف لائن) درستگی گھٹاؤ درستگی وداؤ - geohash چُنو تھلے سکرول کرو فائل تصویر @@ -280,8 +279,6 @@ bitchat توانڈی لوکیشن نئیں ٹریک کردا کوئی وی نیڑے نئیں … (تُسی) - geohash چُنن لئی پین تے زوم کرو - چُنو پیغام لکھو … ذکر v%1$s @@ -382,4 +379,14 @@ بولی چݨو سپیم فلٹر اوہناں لوکاں دے عوامی پیغام لُکاؤ جو بہت تیزی نال پیغام بھیجدے، گلّاں دُہراوندے یا بہت سارے لنک بھیجدے نیں۔ + لائیو + جڑ رہیا اے + آف لائن + گھماؤ + زوم + کسے بندو تے ٹیپ کرو + کوئی تازہ سرگرمی نہیں + پورا گلوب وکھاؤ + %1$d فعال چیٹاں، %2$d لوک آن لائن + %1$d لوک، پچھلے گھنٹے وچ %2$d سنیہے diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 331134801..ae3bbb9f3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -196,7 +196,6 @@ Ulubiony offline Zmniejsz precyzję Zwiększ precyzję - Wybierz geohash Przewiń na dół Plik Obraz @@ -331,8 +330,6 @@ · ⧉ nikogo w pobliżu... (ty) - przesuwaj i powiększaj, aby wybrać geohash - wybierz napisz wiadomość... @%1$s wzmianka @@ -400,4 +397,14 @@ Wybierz język Filtr spamu Ukrywaj publiczne wiadomości od osób, które piszą zbyt szybko, powtarzają się lub wysyłają zbyt wiele linków. + na żywo + łączenie + offline + obracaj + przybliż + dotknij kropki + brak ostatniej aktywności + Pokaż cały glob + Aktywne czaty: %1$d, osoby online: %2$d + Osoby: %1$d, wiadomości w ostatniej godzinie: %2$d diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 3f2fbfc0f..dccdb34c1 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -145,7 +145,6 @@ Favorito (offline) Diminuir precisão Aumentar precisão - Selecionar geohash Rolar para o fim Arquivo Imagem @@ -280,8 +279,6 @@ O bitchat NÃO rastreia sua localização ninguém por perto… (você) - arraste/amplie para selecionar um geohash - selecionar Digite uma mensagem… mencionar v%1$s @@ -394,4 +391,14 @@ Selecionar idioma Filtro de spam Ocultar mensagens públicas de pessoas que enviam mensagens rápido demais, se repetem ou enviam links em excesso. + ao vivo + conectando + offline + girar + zoom + toque em um ponto + sem atividade recente + Mostrar o globo inteiro + %1$d chats ativos, %2$d pessoas online + %1$d pessoas, %2$d mensagens na última hora diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0127bf346..6171b7eaf 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -145,7 +145,6 @@ Favorito (offline) Diminuir precisão Aumentar precisão - Selecionar geohash Deslocar para baixo Ficheiro Imagem @@ -280,8 +279,6 @@ O bitchat NÃO rastreia a sua localização ninguém por perto… (você) - arraste e amplie para selecionar um geohash - selecionar Digite uma mensagem… mencionar v%1$s @@ -395,4 +392,14 @@ Selecionar idioma Filtro de spam Ocultar mensagens públicas de pessoas que publicam demasiado depressa, se repetem ou enviam demasiadas ligações. + ao vivo + a ligar + offline + rodar + zoom + toque num ponto + sem atividade recente + Mostrar o globo inteiro + %1$d conversas ativas, %2$d pessoas online + %1$d pessoas, %2$d mensagens na última hora diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e7738dc6b..27d52e92d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -134,7 +134,6 @@ Избранное (оффлайн) Уменьшить точность Увеличить точность - Выбрать geohash Прокрутить вниз Файл Изображение @@ -259,8 +258,6 @@ · ⧉ никого рядом… (вы) - прокрутите/масштабируйте для выбора geohash - выбрать введите сообщение… @%1$s упоминание @@ -382,4 +379,14 @@ Выбрать язык Спам-фильтр Скрывать публичные сообщения от тех, кто пишет слишком быстро, повторяется или отправляет слишком много ссылок. + в эфире + подключение + офлайн + вращать + масштаб + нажмите на точку + нет недавней активности + Показать весь глобус + Активных чатов: %1$d, людей онлайн: %2$d + Людей: %1$d, сообщений за последний час: %2$d diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 2cc58f35e..bd450d153 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -134,7 +134,6 @@ Favorit (offline) Minska precision Öka precision - Välj geohash Rulla längst ned Fil Bild @@ -259,8 +258,6 @@ · ⧉ ingen i närheten… (du) - panorera och zooma för att välja geohash - välj skriv ett meddelande… @%1$s nämn @@ -380,4 +377,14 @@ Välj språk Spamfilter Dölj offentliga meddelanden från personer som skriver för snabbt, upprepar sig eller skickar för många länkar. + live + ansluter + offline + snurra + zooma + tryck på en prick + ingen aktivitet nyligen + Visa hela jordklotet + %1$d aktiva chattar, %2$d personer online + %1$d personer, %2$d meddelanden den senaste timmen diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 6b1b6f2da..c14ee9f39 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -188,7 +188,6 @@ ஆஃப்லைன் பிடித்தவை துல்லியத்தைக் குறை துல்லியத்தை அதிகரி - ஜியோஹாஷைத் தேர்ந்தெடு கீழே ஸ்க்ரோல் செய் கோப்பு படம் @@ -336,8 +335,6 @@ · ⧉ யாரும் அருகில் இல்லை… (நீங்கள்) - ஜியோஹாஷைத் தேர்ந்தெடுக்க பான் செய்து ஜூம் செய்யவும் - தேர்ந்தெடு செய்தியை உள்ளிடவும்… @%1$s குறிப்பிடு @@ -392,4 +389,14 @@ மொழியைத் தேர்ந்தெடுக்கவும் ஸ்பேம் வடிகட்டி மிக வேகமாகச் செய்திகளை அனுப்புபவர்கள், ஒரே கருத்தைத் திரும்பத் திரும்பக் கூறுபவர்கள் அல்லது அதிக இணைப்புகளை அனுப்புபவர்களின் பொதுச் செய்திகளை மறைக்கவும். + நேரலை + இணைக்கிறது + ஆஃப்லைன் + சுழற்று + பெரிதாக்கு + ஒரு புள்ளியைத் தட்டவும் + சமீபத்திய செயல்பாடு இல்லை + முழு உலக உருண்டையைக் காட்டு + %1$d செயலில் உள்ள அரட்டைகள், %2$d பேர் ஆன்லைனில் + %1$d பேர், கடந்த ஒரு மணி நேரத்தில் %2$d செய்திகள் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index a6e22fde8..626580d13 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -145,7 +145,6 @@ รายการโปรด (ออฟไลน์) ลดความแม่นยำ เพิ่มความแม่นยำ - เลือก geohash เลื่อนไปด้านล่างสุด ไฟล์ รูปภาพ @@ -280,8 +279,6 @@ bitchat ไม่ติดตามตำแหน่งของคุณ ไม่มีใครอยู่ใกล้… (คุณ) - ลาก/ซูมเพื่อเลือก geohash - เลือก พิมพ์ข้อความ… กล่าวถึง v%1$s @@ -382,4 +379,14 @@ เลือกภาษา ตัวกรองสแปม ซ่อนข้อความสาธารณะจากผู้ที่ส่งข้อความเร็วเกินไป ส่งข้อความซ้ำ หรือส่งลิงก์จำนวนมากเกินไป + สด + กำลังเชื่อมต่อ + ออฟไลน์ + หมุน + ซูม + แตะที่จุด + ไม่มีกิจกรรมล่าสุด + แสดงลูกโลกทั้งใบ + แชทที่ใช้งานอยู่ %1$d รายการ ออนไลน์ %2$d คน + %1$d คน ข้อความ %2$d รายการในชั่วโมงที่ผ่านมา diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e2787768c..0bbd73556 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -134,7 +134,6 @@ Favori (çevrimdışı) Hassasiyeti azalt Hassasiyeti artır - Geohash seç Alta kaydır Dosya Görsel @@ -259,8 +258,6 @@ · ⧉ yakında kimse yok… (sen) - geohash seçmek için kaydır/zoom yap - seç mesaj yaz… @%1$s bahset @@ -380,4 +377,14 @@ Dil seçin Spam filtresi Çok hızlı mesaj gönderen, kendini tekrarlayan veya çok fazla bağlantı paylaşan kişilerin herkese açık mesajlarını gizle. + canlı + bağlanıyor + çevrimdışı + döndür + yakınlaştır + bir noktaya dokun + yakın zamanda etkinlik yok + Tüm küreyi göster + %1$d etkin sohbet, %2$d kişi çevrimiçi + %1$d kişi, son bir saatte %2$d mesaj diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8ac22faca..adbc774c8 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -181,7 +181,6 @@ Офлайн обране Зменшити точність Збільшити точність - Вибрати геохеш Прогорнути донизу Файл Зображення @@ -325,8 +324,6 @@ · ⧉ нікого немає поруч... (ви) - панорамуйте та масштабуйте, щоб вибрати геохеш - вибрати введіть повідомлення... @%1$s згадка @@ -402,4 +399,14 @@ Вибрати мову Спам-фільтр Приховувати публічні повідомлення від тих, хто пише надто швидко, повторюється або надсилає забагато посилань. + наживо + підключення + офлайн + обертати + масштаб + торкніться точки + немає нещодавньої активності + Показати весь глобус + Активних чатів: %1$d, людей онлайн: %2$d + Людей: %1$d, повідомлень за останню годину: %2$d diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 4be8ca54b..f3df3bbd2 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -145,7 +145,6 @@ پسندیدہ (آف لائن) درستگی کم کریں درستگی بڑھائیں - geohash منتخب کریں نیچے اسکرول کریں فائل تصویر @@ -280,8 +279,6 @@ bitchat آپ کا مقام ٹریک نہیں کرتا یہاں کوئی نہیں… (آپ) - geohash منتخب کرنے کے لیے پین/زوم کریں - منتخب کریں پیغام لکھیں… ذکر v%1$s @@ -395,4 +392,14 @@ زبان منتخب کریں اسپام فلٹر ایسے لوگوں کے عوامی پیغامات چھپائیں جو بہت تیزی سے پیغامات بھیجتے ہیں، اپنی بات دہراتے ہیں یا بہت زیادہ لنکس بھیجتے ہیں۔ + لائیو + جڑ رہا ہے + آف لائن + گھمائیں + زوم + کسی نقطے پر ٹیپ کریں + کوئی حالیہ سرگرمی نہیں + پورا گلوب دکھائیں + %1$d فعال چیٹس، %2$d لوگ آن لائن + %1$d لوگ، پچھلے گھنٹے میں %2$d پیغامات diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 10073675e..6847b366d 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -145,7 +145,6 @@ Yêu thích (ngoại tuyến) Giảm độ chính xác Tăng độ chính xác - Chọn geohash Cuộn xuống dưới File Hình ảnh @@ -280,8 +279,6 @@ bitchat KHÔNG theo dõi vị trí của bạn không có ai xung quanh … (bạn) - di chuyển và zoom để chọn geohash - chọn Nhập tin nhắn … nhắc đến v%1$s @@ -382,4 +379,14 @@ Chọn ngôn ngữ Bộ lọc thư rác Ẩ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. + trực tiếp + đang kết nối + ngoại tuyến + xoay + thu phóng + chạm vào một chấm + không có hoạt động gần đây + Hiển thị toàn bộ địa cầu + %1$d cuộc trò chuyện đang hoạt động, %2$d người trực tuyến + %1$d người, %2$d tin nhắn trong giờ qua diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b2bac216c..46a10b65f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -196,7 +196,6 @@ 离线收藏 降低精度 提高精度 - 选择 Geohash 滚动到底部 文件 图片 @@ -331,8 +330,6 @@ · ⧉ 附近没有人… (你) - 拖动并缩放以选择一个 geohash - 选择 输入消息… @%1$s 提及 @@ -390,4 +387,14 @@ 选择语言 垃圾消息过滤器 隐藏发送过快、重复发言或发送过多链接的用户的公开消息。 + 实时 + 连接中 + 离线 + 旋转 + 缩放 + 点按圆点 + 最近没有活动 + 显示整个地球 + %1$d 个活跃聊天,%2$d 人在线 + %1$d 人,过去一小时 %2$d 条消息 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ed78c193e..6ea847ae7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -196,7 +196,6 @@ 離線最愛 降低精確度 提高精確度 - 選擇地理雜湊 捲動到底部 檔案 圖片 @@ -331,8 +330,6 @@ · ⧉ 附近沒有人… (你) - 平移並縮放以選擇地理雜湊 - 選擇 輸入訊息… @%1$s 提及 @@ -391,4 +388,14 @@ 選擇語言 垃圾訊息篩選器 隱藏傳送過快、重複發言或傳送過多連結的使用者的公開訊息。 + 即時 + 連線中 + 離線 + 旋轉 + 縮放 + 點一下圓點 + 最近沒有活動 + 顯示整個地球 + %1$d 個活躍聊天,%2$d 人在線上 + %1$d 人,過去一小時 %2$d 則訊息 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6fc8dce19..71e7bccf7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -145,7 +145,6 @@ 收藏(离线) 降低精度 提高精度 - 选择 geohash 滚动到底部 文件 图片 @@ -275,8 +274,6 @@ · ⧉ 附近无人… (你) - 拖动/缩放以选择 geohash - 选择 输入消息… @%1$s 提及 @@ -406,4 +403,14 @@ 选择语言 垃圾消息过滤器 隐藏发送过快、重复发言或发送过多链接的用户的公开消息。 + 实时 + 连接中 + 离线 + 旋转 + 缩放 + 点按圆点 + 最近没有活动 + 显示整个地球 + %1$d 个活跃聊天,%2$d 人在线 + %1$d 人,过去一小时 %2$d 条消息 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f20c1eb0..ea65e3b9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -362,7 +362,6 @@ Offline favorite Decrease precision Increase precision - Select geohash Scroll to bottom File Image @@ -584,8 +583,6 @@ · ⧉ Nobody around… (you) - Drag to spin · Pinch to zoom · Tap to focus - Select Type a message… @%1$s Mention @@ -663,4 +660,14 @@ App language System default Select language + live + connecting + offline + spin + zoom + tap a dot + no recent activity + Show the whole globe + %1$d active chats, %2$d people online + %1$d people, %2$d messages in the last hour diff --git a/app/src/test/java/com/bitchat/android/nostr/GeohashActivityAggregatorTest.kt b/app/src/test/java/com/bitchat/android/nostr/GeohashActivityAggregatorTest.kt new file mode 100644 index 000000000..6f9d6b32c --- /dev/null +++ b/app/src/test/java/com/bitchat/android/nostr/GeohashActivityAggregatorTest.kt @@ -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())) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeActivityLayerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeActivityLayerTest.kt new file mode 100644 index 000000000..37de2ca31 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeActivityLayerTest.kt @@ -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, 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)) + } +}