diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 809cd1e6..76fea7a1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index c845ac0e..687f79bc 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -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 + } + } } 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 9572b010..92f4afe8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -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 */ diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt index 0995940b..5a8f06fd 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt @@ -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", diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt index b8a91218..2c80d17e 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 320faca0..d1e34307 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 9fdbc19a..0a0ea6a7 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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)}" diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index b2e494a6..a6cbe8e9 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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(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 = { diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 9d8b3144..c1e58963 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -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) } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt b/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt new file mode 100644 index 00000000..f364ba30 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt @@ -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 { + if (text.isEmpty()) return emptyList() + val matches = mutableListOf() + 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 + } +} + + diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 857160ec..a36e71fe 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -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) } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index c0308a70..cf549d76 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -501,7 +501,6 @@ class PrivateChatManager( } if (didMerge) { - targetList.sortBy { it.timestamp } currentChats[targetPeerID] = targetList state.setPrivateChats(currentChats)