UI geohash notifications (#325)

* location name in notifications

* remove tests

* panic nostr

* mentions with hashes, otherwise none

* fix timestamps

* parse geohashes in messages

* works

* fix country name

* mention notifications work
This commit is contained in:
callebtc
2025-08-28 17:47:47 +02:00
committed by GitHub
parent 3ea2aed9a4
commit 550520795f
12 changed files with 393 additions and 34 deletions
@@ -88,12 +88,12 @@ class MessageHandler(private val myPeerID: String) {
return
}
// Create BitchatMessage - use local system time for incoming messages
// Create BitchatMessage - preserve source packet timestamp
val message = BitchatMessage(
id = privateMessage.messageID,
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
content = privateMessage.content,
timestamp = java.util.Date(), // Use local system time instead of packet timestamp
timestamp = java.util.Date(packet.timestamp.toLong()),
isRelay = false,
originalSender = null,
isPrivate = true,
@@ -343,7 +343,7 @@ class MessageHandler(private val myPeerID: String) {
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
timestamp = Date()
timestamp = Date(packet.timestamp.toLong())
)
delegate?.onMessageReceived(message)
@@ -369,7 +369,7 @@ class MessageHandler(private val myPeerID: String) {
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
timestamp = Date()
timestamp = Date(packet.timestamp.toLong())
)
delegate?.onMessageReceived(message)
@@ -135,6 +135,76 @@ class NostrGeohashService(
Log.i(TAG, "✅ Nostr integration initialized with gift wrap subscription")
}
}
/**
* Panic/reset flow for Nostr + geohash systems.
* - Disconnect and clear all Nostr relay subscriptions and caches
* - Delete device-wide Nostr keys and per-geohash seed cache
* - Clear geohash participants, nicknames, message history
* - Recreate identity and reconnect; reinitialize subscriptions from scratch
*/
fun panicResetNostrAndGeohash() {
coroutineScope.launch {
try {
val relayManager = NostrRelayManager.getInstance(application)
// 1) Disconnect from all relays
relayManager.disconnect()
// 2) Clear all subscription tracking and caches
relayManager.clearAllSubscriptions()
// 3) Clear Nostr identity (npub/private) and geohash identity cache/seed
try {
NostrIdentityBridge.clearAllAssociations(application)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear Nostr associations: ${e.message}")
}
// 4) Clear local geohash state (participants, nicknames, message history)
try {
geohashParticipants.clear()
geoNicknames.clear()
geohashMessageHistory.clear()
processedNostrEvents.clear()
processedNostrEventOrder.clear()
currentGeohashSubscriptionId = null
currentGeohashDmSubscriptionId = null
currentGeohash = null
state.setGeohashPeople(emptyList())
state.setTeleportedGeo(emptySet())
state.setGeohashParticipantCounts(emptyMap())
// Stop any timers/jobs
geohashSamplingJob?.cancel()
geohashSamplingJob = null
geoParticipantsTimer?.cancel()
geoParticipantsTimer = null
} catch (e: Exception) {
Log.e(TAG, "Failed to clear geohash state: ${e.message}")
}
// 5) Recreate identity and reconnect, then re-subscribe
try {
// Touch identity to recreate
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
if (identity == null) {
Log.w(TAG, "No identity after reset; skipping subscriptions")
return@launch
}
// Reconnect to relays and reinitialize subscriptions
relayManager.connect()
initializeNostrIntegration()
// Re-establish location channel state observers and subscriptions
initializeLocationChannelState()
} catch (e: Exception) {
Log.e(TAG, "Failed to reinitialize Nostr after reset: ${e.message}")
}
} catch (e: Exception) {
Log.e(TAG, "panicResetNostrAndGeohash error: ${e.message}")
}
}
}
/**
* Initialize location channel state
@@ -610,7 +680,6 @@ class NostrGeohashService(
private fun storeGeohashMessage(geohash: String, message: BitchatMessage) {
val messages = geohashMessageHistory.getOrPut(geohash) { mutableListOf() }
messages.add(message)
messages.sortBy { it.timestamp }
// Limit message history to prevent memory issues
if (messages.size > maxGeohashMessages) {
@@ -1160,11 +1229,12 @@ class NostrGeohashService(
return@launch
}
val senderName = displayNameForNostrPubkey(event.pubkey)
val senderHandle = displayNameForNostrPubkey(event.pubkey)
val senderName = displayNameForNostrPubkeyUI(event.pubkey)
val content = event.content
// Use local time instead of Nostr event time for consistent message ordering
val messageTimestamp = Date()
// Preserve Nostr event time to avoid reordering
val messageTimestamp = Date(event.createdAt.toLong() * 1000L)
// Note: mentions parsing needs peer nicknames parameter
// val mentions = messageManager.parseMentions(content, peerNicknames, nickname)
@@ -1174,6 +1244,7 @@ class NostrGeohashService(
content = content,
timestamp = messageTimestamp,
isRelay = false,
originalSender = senderHandle,
senderPeerID = "nostr:${event.pubkey.take(8)}",
mentions = null, // mentions need to be passed from outside
channel = "#$geohash"
@@ -1231,12 +1302,32 @@ class NostrGeohashService(
Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage")
withContext(Dispatchers.Main) {
// Sanitize mention for notifications: hide '#hash' from @username#hash if not needed
val contentForNotification = if (isMention) {
var sanitized = content
try {
val myGeoIdentity = NostrIdentityBridge.deriveIdentity(
forGeohash = geohash,
context = application
)
val myDisplay = displayNameForNostrPubkeyUI(myGeoIdentity.publicKeyHex)
val needsDisambiguation = myDisplay.contains("#")
if (!needsDisambiguation) {
val pattern = ("@" + java.util.regex.Pattern.quote(currentNickname) + "#[a-fA-F0-9]{4}\\b").toRegex()
sanitized = sanitized.replace(pattern, "@" + currentNickname)
}
} catch (_: Exception) { }
sanitized
} else content
val locationName = getLocationNameForGeohash(geohash)
notificationManager.showGeohashNotification(
geohash = geohash,
senderNickname = senderName,
messageContent = content,
messageContent = contentForNotification,
isMention = isMention,
isFirstMessage = isFirstMessage
isFirstMessage = isFirstMessage,
locationName = locationName
)
}
}
@@ -1365,7 +1456,8 @@ class NostrGeohashService(
}
if (messageExists) return@launch
val senderName = displayNameForNostrPubkey(senderPubkey)
val senderHandle = displayNameForNostrPubkey(senderPubkey)
val senderName = displayNameForNostrPubkeyUI(senderPubkey)
val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey
val message = BitchatMessage(
@@ -1377,6 +1469,7 @@ class NostrGeohashService(
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = convKey,
originalSender = senderHandle,
deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered(
to = state.getNicknameValue() ?: "Unknown",
at = Date()
@@ -1453,6 +1546,71 @@ class NostrGeohashService(
//Log.v(TAG, "❌ No cached nickname for ${pubkeyHex.take(8)}, using anon")
return "anon#$suffix"
}
/**
* UI display name for Nostr pubkey (conditionally hides #hash when not needed)
* Used in chat window sender labels, geohash people list, and notifications.
*/
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
val pubkeyLower = pubkeyHex.lowercase()
val suffix = pubkeyHex.takeLast(4)
val currentGeohash = this.currentGeohash
// Determine base name (self nickname, cached nickname, or anon)
val baseName: String = try {
if (currentGeohash != null) {
val myGeoIdentity = NostrIdentityBridge.deriveIdentity(
forGeohash = currentGeohash,
context = application
)
if (myGeoIdentity.publicKeyHex.lowercase() == pubkeyLower) {
state.getNicknameValue() ?: "anon"
} else {
geoNicknames[pubkeyLower] ?: "anon"
}
} else {
geoNicknames[pubkeyLower] ?: "anon"
}
} catch (_: Exception) {
geoNicknames[pubkeyLower] ?: "anon"
}
// If not in a geohash context, no need for disambiguation
if (currentGeohash == null) return baseName
// Count active participants sharing the same base name within 5 minutes
return try {
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
val participants = geohashParticipants[currentGeohash] ?: emptyMap()
var count = 0
for ((participantKey, lastSeen) in participants) {
if (lastSeen.before(cutoff)) continue
val participantLower = participantKey.lowercase()
val name = if (participantLower == pubkeyLower) baseName else (geoNicknames[participantLower] ?: "anon")
if (name.equals(baseName, ignoreCase = true)) {
count++
if (count > 1) break
}
}
if (!participants.containsKey(pubkeyLower)) {
count += 1
}
if (count > 1) "$baseName#$suffix" else baseName
} catch (_: Exception) {
baseName
}
}
/**
* Mention handle for a Nostr pubkey (ALWAYS includes #hash)
* Use for mentions, hug/slap targets, and mention detection contexts.
*/
fun mentionHandleForNostrPubkey(pubkeyHex: String): String {
return displayNameForNostrPubkey(pubkeyHex)
}
// MARK: - Color System
@@ -1605,5 +1763,37 @@ class NostrGeohashService(
private fun isGeohashUserBlocked(pubkeyHex: String): Boolean {
return dataManager.isGeohashUserBlocked(pubkeyHex)
}
/**
* Get location name for a geohash if available
*/
private fun getLocationNameForGeohash(geohash: String): String? {
return try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application)
val locationNames = locationManager.locationNames.value ?: return null
val available = locationManager.availableChannels.value ?: emptyList()
// Determine the level from geohash length
val level = when (geohash.length) {
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
}
// Only show location name if the notification's geohash matches the device's current geohash at that level
val currentAtLevel = available.firstOrNull { it.level == level }?.geohash
if (currentAtLevel != null && currentAtLevel.equals(geohash, ignoreCase = true)) {
locationNames[level]
} else {
null
}
} catch (e: Exception) {
Log.w(TAG, "Failed to get location name for geohash $geohash: ${e.message}")
null
}
}
}
@@ -456,6 +456,31 @@ class NostrRelayManager private constructor() {
}
}
/**
* Clear all subscription tracking, message handlers, routing caches, and queued messages.
* Intended for panic/reset flows prior to reconnecting and re-subscribing from scratch.
*/
fun clearAllSubscriptions() {
try {
// Clear persistent subscription tracking
activeSubscriptions.clear()
messageHandlers.clear()
subscriptions.clear()
// Clear routing caches (per-geohash relay selections)
geohashToRelays.clear()
// Clear any queued messages waiting to be sent
synchronized(messageQueueLock) {
messageQueue.clear()
}
Log.i(TAG, "🧹 Cleared all Nostr subscriptions and routing caches")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear subscriptions: ${e.message}")
}
}
/**
* Get detailed status for all relays
*/
@@ -139,7 +139,7 @@ private fun LocationDisabledContent(
)
Text(
text = "• Bluetooth device scanning (Android requirement)\n" +
text = "• Bluetooth device scanning\n" +
"• Discovering nearby users on mesh network\n" +
"• Geohash chat feature\n" +
"• No tracking or location collection",
@@ -60,7 +60,7 @@ object ConversationAliasResolver {
}
}
if (didMerge) {
targetList.sortBy { it.timestamp }
// Preserve arrival order; do not sort by timestamp
currentChats[targetPeerID] = targetList
state.setPrivateChats(currentChats)
@@ -5,6 +5,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.BluetoothMeshService
@@ -80,11 +81,11 @@ fun formatMessageAsAnnotatedString(
builder.append(baseName)
val nicknameEnd = builder.length
// Add click annotation for nickname (store full sender name with hash)
// Add click annotation for nickname (store canonical sender name with hash if available)
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = message.sender, // Store full sender name with hash
annotation = (message.originalSender ?: message.sender),
start = nicknameStart,
end = nicknameEnd
)
@@ -257,6 +258,31 @@ private fun appendIOSFormattedContent(
for (match in mentionMatches) {
allMatches.add(match.range to "mention")
}
// Add standalone geohash matches (e.g., "#9q") that are not part of another word
// We use MessageSpecialParser to find exact ranges; then merge with existing ranges avoiding overlaps
val geoMatches = MessageSpecialParser.findStandaloneGeohashes(content)
for (gm in geoMatches) {
val range = gm.start until gm.endExclusive
if (!overlapsMention(range)) {
allMatches.add(range to "geohash")
}
}
// Remove generic hashtag matches that overlap with detected geohash ranges to avoid duplicate rendering
fun rangesOverlap(a: IntRange, b: IntRange): Boolean {
return a.first < b.last && a.last > b.first
}
val geoRanges = allMatches.filter { it.second == "geohash" }.map { it.first }
if (geoRanges.isNotEmpty()) {
val iterator = allMatches.listIterator()
while (iterator.hasNext()) {
val (range, type) = iterator.next()
if (type == "hashtag" && geoRanges.any { rangesOverlap(range, it) }) {
iterator.remove()
}
}
}
allMatches.sortBy { it.first.first }
@@ -327,7 +353,7 @@ private fun appendIOSFormattedContent(
}
}
"hashtag" -> {
// iOS-style: render hashtags like normal content (no special styling)
// Render general hashtags like normal content
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
@@ -342,6 +368,37 @@ private fun appendIOSFormattedContent(
}
builder.pop()
}
else -> {
if (type == "geohash") {
// Style geohash in blue, underlined, and add click annotation
builder.pushStyle(SpanStyle(
color = Color(0xFF007AFF),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
textDecoration = TextDecoration.Underline
))
val start = builder.length
builder.append(matchText)
val end = builder.length
val geohash = matchText.removePrefix("#").lowercase()
builder.addStringAnnotation(
tag = "geohash_click",
annotation = geohash,
start = start,
end = end
)
builder.pop()
} else {
// Fallback: treat as normal text
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
))
builder.append(matchText)
builder.pop()
}
}
}
lastEnd = range.last + 1
@@ -556,8 +556,12 @@ class ChatViewModel(
// Clear all notifications
notificationManager.clearAllNotifications()
// Clear geohash message history
nostrGeohashService.clearGeohashMessageHistory()
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
try {
nostrGeohashService.panicResetNostrAndGeohash()
} catch (e: Exception) {
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
}
// Reset nickname
val newNickname = "anon${Random.nextInt(1000, 9999)}"
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import com.bitchat.android.model.BitchatMessage
@@ -199,6 +200,7 @@ private fun MessageTextWithClickableNicknames(
if (!isSelf && (onNicknameClick != null || onMessageLongPress != null)) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedText,
@@ -216,6 +218,33 @@ private fun MessageTextWithClickableNicknames(
val nickname = nicknameAnnotations.first().item
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick?.invoke(nickname)
return@detectTapGestures
}
// Handle geohash click to teleport
val geohashAnnotations = annotatedText.getStringAnnotations(
tag = "geohash_click",
start = offset,
end = offset
)
if (geohashAnnotations.isNotEmpty()) {
val geohash = geohashAnnotations.first().item
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
context
)
// Select appropriate precision based on length
val level = when (geohash.length) {
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
}
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
locationManager.setTeleported(true)
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
},
onLongPress = {
@@ -21,7 +21,6 @@ class MessageManager(private val state: ChatState) {
fun addMessage(message: BitchatMessage) {
val currentMessages = state.getMessagesValue().toMutableList()
currentMessages.add(message)
currentMessages.sortBy { it.timestamp }
state.setMessages(currentMessages)
}
@@ -39,7 +38,6 @@ class MessageManager(private val state: ChatState) {
val channelMessageList = currentChannelMessages[channel]?.toMutableList() ?: mutableListOf()
channelMessageList.add(message)
channelMessageList.sortBy { it.timestamp }
currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages)
@@ -83,7 +81,6 @@ class MessageManager(private val state: ChatState) {
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
chatMessages.sortBy { it.timestamp }
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
@@ -103,7 +100,6 @@ class MessageManager(private val state: ChatState) {
}
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
chatMessages.sortBy { it.timestamp }
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
}
@@ -0,0 +1,53 @@
package com.bitchat.android.ui
import androidx.compose.ui.text.AnnotatedString
/**
* Utilities for parsing special tokens in chat messages (geohashes, etc.).
*/
object MessageSpecialParser {
// Standalone geohash pattern like "#9q" or longer. Word boundaries enforced.
// Geohash alphabet is base32: 0123456789bcdefghjkmnpqrstuvwxyz
private val standaloneGeohashRegex = Regex("(^|[^A-Za-z0-9_#])#([0-9bcdefghjkmnpqrstuvwxyz]{2,})($|[^A-Za-z0-9_])", RegexOption.IGNORE_CASE)
data class GeohashMatch(val start: Int, val endExclusive: Int, val geohash: String)
/**
* Finds standalone geohashes within [text]. A match is returned only when
* the '#' token is not part of another word (e.g., not in '@anon#9qk2').
*/
fun findStandaloneGeohashes(text: String): List<GeohashMatch> {
if (text.isEmpty()) return emptyList()
val matches = mutableListOf<GeohashMatch>()
var index = 0
while (index < text.length) {
val m = standaloneGeohashRegex.find(text, index) ?: break
// Adjust to only cover the geohash token starting at '#'
val fullRange = m.range
// Find the '#' within this match substring
val sub = text.substring(fullRange)
val hashPos = sub.indexOf('#')
if (hashPos >= 0) {
val tokenStart = fullRange.first + hashPos
// Consume '#' + geohash letters
var cursor = tokenStart + 1
while (cursor < text.length) {
val ch = text[cursor].lowercaseChar()
val isGeoChar = (ch in '0'..'9') || (ch in "bcdefghjkmnpqrstuvwxyz")
if (!isGeoChar) break
cursor++
}
val token = text.substring(tokenStart + 1, cursor)
if (token.length >= 2) {
matches.add(GeohashMatch(tokenStart, cursor, token.lowercase()))
}
index = cursor
} else {
index = fullRange.last + 1
}
}
return matches
}
}
@@ -82,7 +82,8 @@ class NotificationManager(
val messageContent: String,
val timestamp: Long,
val isMention: Boolean,
val isFirstMessage: Boolean
val isFirstMessage: Boolean,
val locationName: String? = null
)
init {
@@ -397,7 +398,8 @@ class NotificationManager(
senderNickname: String,
messageContent: String,
isMention: Boolean = false,
isFirstMessage: Boolean = false
isFirstMessage: Boolean = false,
locationName: String? = null
) {
// Only show notifications if app is in background OR user is not viewing this specific geohash
val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash)
@@ -415,7 +417,8 @@ class NotificationManager(
messageContent = messageContent,
timestamp = System.currentTimeMillis(),
isMention = isMention,
isFirstMessage = isFirstMessage
isFirstMessage = isFirstMessage,
locationName = locationName
)
// Add to pending notifications for this geohash
@@ -453,12 +456,13 @@ class NotificationManager(
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
// Build notification content
// Build notification content with location name if available
val geohashDisplay = latestNotification.locationName?.let { "$it (#$geohash)" } ?: "#$geohash"
val contentTitle = when {
mentionCount > 0 && firstMessageCount > 0 -> "Mentioned in #$geohash (+${messageCount - 1} more)"
mentionCount > 0 -> if (mentionCount == 1) "Mentioned in #$geohash" else "$mentionCount mentions in #$geohash"
firstMessageCount > 0 -> "New activity in #$geohash"
else -> "Messages in #$geohash"
mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> "Mentioned in $geohashDisplay (+${messageCount - 1} more)"
mentionCount > 0 -> if (mentionCount == 1) "Mentioned in $geohashDisplay" else "$mentionCount mentions in $geohashDisplay"
firstMessageCount > 0 -> "New activity in $geohashDisplay"
else -> "Messages in $geohashDisplay"
}
val contentText = when {
@@ -564,10 +568,12 @@ class NotificationManager(
pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) ->
val mentionCount = notifications.count { it.isMention }
val messageCount = notifications.size
val latestNotification = notifications.last()
val geohashDisplay = latestNotification.locationName?.let { "$it (#$geohash)" } ?: "#$geohash"
val line = when {
mentionCount > 0 -> "#$geohash: $mentionCount mentions (+${messageCount - mentionCount} more)"
messageCount == 1 -> "#$geohash: 1 message"
else -> "#$geohash: $messageCount messages"
mentionCount > 0 -> "$geohashDisplay: $mentionCount mentions (+${messageCount - mentionCount} more)"
messageCount == 1 -> "$geohashDisplay: 1 message"
else -> "$geohashDisplay: $messageCount messages"
}
style.addLine(line)
}
@@ -501,7 +501,6 @@ class PrivateChatManager(
}
if (didMerge) {
targetList.sortBy { it.timestamp }
currentChats[targetPeerID] = targetList
state.setPrivateChats(currentChats)