diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 2e428a28..c18e284b 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -658,7 +658,7 @@ class MainActivity : ComponentActivity() { } /** - * Handle intents from notification clicks - open specific private chat + * Handle intents from notification clicks - open specific private chat or geohash chat */ private fun handleNotificationIntent(intent: Intent) { val shouldOpenPrivateChat = intent.getBooleanExtra( @@ -666,18 +666,52 @@ class MainActivity : ComponentActivity() { false ) - if (shouldOpenPrivateChat) { - val peerID = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_PEER_ID) - val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME) + val shouldOpenGeohashChat = intent.getBooleanExtra( + com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_GEOHASH_CHAT, + false + ) + + when { + shouldOpenPrivateChat -> { + val peerID = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_PEER_ID) + val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME) + + if (peerID != null) { + Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification") + + // Open the private chat with this peer + chatViewModel.startPrivateChat(peerID) + + // Clear notifications for this sender since user is now viewing the chat + chatViewModel.clearNotificationsForSender(peerID) + } + } - if (peerID != null) { - Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification") + shouldOpenGeohashChat -> { + val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH) - // Open the private chat with this peer - chatViewModel.startPrivateChat(peerID) - - // Clear notifications for this sender since user is now viewing the chat - chatViewModel.clearNotificationsForSender(peerID) + if (geohash != null) { + Log.d("MainActivity", "Opening geohash chat #$geohash from notification") + + // Switch to the geohash channel - create appropriate geohash channel level + val level = when (geohash.length) { + 7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK + 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD + 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY + 4 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION + 2 -> com.bitchat.android.geohash.GeohashChannelLevel.COUNTRY + else -> com.bitchat.android.geohash.GeohashChannelLevel.CITY // Default fallback + } + val geohashChannel = com.bitchat.android.geohash.GeohashChannel(level, geohash) + val channelId = com.bitchat.android.geohash.ChannelID.Location(geohashChannel) + chatViewModel.selectLocationChannel(channelId) + + // Update current geohash state for notifications + chatViewModel.setCurrentGeohash(geohash) + + // Clear notifications for this geohash since user is now viewing it + chatViewModel.clearNotificationsForGeohash(geohash) + } } } } 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 57357a9c..fd6cc101 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -29,7 +29,8 @@ class NostrGeohashService( private val privateChatManager: PrivateChatManager, private val meshDelegateHandler: MeshDelegateHandler, private val coroutineScope: CoroutineScope, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val notificationManager: com.bitchat.android.ui.NotificationManager ) { companion object { @@ -51,7 +52,8 @@ class NostrGeohashService( privateChatManager: PrivateChatManager, meshDelegateHandler: MeshDelegateHandler, coroutineScope: CoroutineScope, - dataManager: com.bitchat.android.ui.DataManager + dataManager: com.bitchat.android.ui.DataManager, + notificationManager: com.bitchat.android.ui.NotificationManager ): NostrGeohashService { return synchronized(this) { INSTANCE ?: NostrGeohashService( @@ -61,7 +63,8 @@ class NostrGeohashService( privateChatManager, meshDelegateHandler, coroutineScope, - dataManager + dataManager, + notificationManager ).also { INSTANCE = it } } } @@ -751,6 +754,10 @@ class NostrGeohashService( Log.d(TAG, "📡 Switched to mesh channel") // Immediate UI state updates currentGeohash = null + // Update notification manager with current geohash + notificationManager.setCurrentGeohash(null) + // Clear mesh mention notifications since user is now viewing mesh chat + notificationManager.clearMeshMentionNotifications() // Note: Don't clear geoNicknames - keep cached for when we return to location channels stopGeoParticipantsTimer() state.setGeohashPeople(emptyList()) @@ -760,6 +767,10 @@ class NostrGeohashService( is com.bitchat.android.geohash.ChannelID.Location -> { Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") currentGeohash = channel.channel.geohash + // Update notification manager with current geohash + notificationManager.setCurrentGeohash(channel.channel.geohash) + // Clear notifications for this geohash since user is now viewing it + notificationManager.clearNotificationsForGeohash(channel.channel.geohash) // Note: Don't clear geoNicknames - they contain cached nicknames for all geohashes // Load stored messages for this geohash immediately @@ -1023,6 +1034,9 @@ class NostrGeohashService( messageManager.addMessage(message) } + // NOTIFICATION LOGIC: Check for mentions and first messages + checkAndTriggerGeohashNotifications(geohash, senderName, content, message) + Log.d(TAG, "📥 Unified geohash event processed - geohash: $geohash, sender: $senderName, content: ${content.take(50)}") } catch (e: Exception) { @@ -1030,6 +1044,104 @@ class NostrGeohashService( } } + /** + * Check and trigger geohash notifications for mentions and first messages + */ + private fun checkAndTriggerGeohashNotifications( + geohash: String, + senderName: String, + content: String, + message: BitchatMessage + ) { + try { + // Get user's current nickname + val currentNickname = state.getNicknameValue() + if (currentNickname.isNullOrEmpty()) { + return + } + + // Check if this message mentions the current user + val isMention = checkForMention(content, currentNickname) + + // Check if this is the first message in a subscribed geohash chat + val isFirstMessage = checkIfFirstMessage(geohash, message) + + // Only trigger notifications if we have a mention or first message + if (isMention || isFirstMessage) { + Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage") + + notificationManager.showGeohashNotification( + geohash = geohash, + senderNickname = senderName, + messageContent = content, + isMention = isMention, + isFirstMessage = isFirstMessage + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error checking geohash notifications: ${e.message}") + } + } + + /** + * Check if the content mentions the current user with @nickname#hash format + */ + private fun checkForMention(content: String, currentNickname: String): Boolean { + // iOS-style mention pattern: @nickname#1234 or @nickname + val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex() + + return mentionPattern.findAll(content).any { match -> + val mentionWithoutAt = match.groupValues[1] + // Split the mention to get base nickname (without #hash suffix) + val baseName = if (mentionWithoutAt.contains("#")) { + mentionWithoutAt.substringBeforeLast("#") + } else { + mentionWithoutAt + } + + // Check if the base name matches current user's nickname + baseName.equals(currentNickname, ignoreCase = true) + } + } + + /** + * Check if this is the first message in a subscribed geohash chat + */ + private fun checkIfFirstMessage(geohash: String, message: BitchatMessage): Boolean { + // Get the message history for this geohash + val messageHistory = geohashMessageHistory[geohash] ?: return true + + // Filter out our own messages (local echoes and messages from our identity) + val otherUserMessages = messageHistory.filter { msg -> + // Check if this is our own message by comparing sender ID with our geohash identity + try { + val myGeoIdentity = NostrIdentityBridge.deriveIdentity( + forGeohash = geohash, + context = application + ) + val myIdentityId = "nostr:${myGeoIdentity.publicKeyHex.take(8)}" + msg.senderPeerID != myIdentityId && msg.senderPeerID != message.senderPeerID + } catch (e: Exception) { + // If we can't determine identity, assume it's not our message + msg.senderPeerID != message.senderPeerID + } + } + + // This is a first message if there are no other user messages and this isn't from us + val isFromUs = try { + val myGeoIdentity = NostrIdentityBridge.deriveIdentity( + forGeohash = geohash, + context = application + ) + val myIdentityId = "nostr:${myGeoIdentity.publicKeyHex.take(8)}" + message.senderPeerID == myIdentityId + } catch (e: Exception) { + false + } + + return otherUserMessages.isEmpty() && !isFromUs + } + /** * Handle geohash DM event (private messages in geohash context) - iOS compatible */ 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 614d235f..2a91c22a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -69,7 +69,8 @@ class ChatViewModel( privateChatManager = privateChatManager, meshDelegateHandler = meshDelegateHandler, coroutineScope = viewModelScope, - dataManager = dataManager + dataManager = dataManager, + notificationManager = notificationManager ) // Expose state through LiveData (maintaining the same interface) @@ -213,6 +214,8 @@ class ChatViewModel( privateChatManager.endPrivateChat() // Notify notification manager that no private chat is active setCurrentPrivateChatPeer(null) + // Clear mesh mention notifications since user is now back in mesh chat + clearMeshMentionNotifications() } // MARK: - Message Sending @@ -388,11 +391,28 @@ class ChatViewModel( notificationManager.setCurrentPrivateChatPeer(peerID) } + fun setCurrentGeohash(geohash: String?) { + // Update notification manager with current geohash for notification logic + notificationManager.setCurrentGeohash(geohash) + } + fun clearNotificationsForSender(peerID: String) { // Clear notifications when user opens a chat notificationManager.clearNotificationsForSender(peerID) } + fun clearNotificationsForGeohash(geohash: String) { + // Clear notifications when user opens a geohash chat + notificationManager.clearNotificationsForGeohash(geohash) + } + + /** + * Clear mesh mention notifications when user opens mesh chat + */ + fun clearMeshMentionNotifications() { + notificationManager.clearMeshMentionNotifications() + } + // MARK: - Command Autocomplete (delegated) fun updateCommandSuggestions(input: String) { diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 8919a1d3..30289e0a 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -102,7 +102,7 @@ fun LocationChannelsSheet( ) Text( - text = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.", + text = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy.", fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 9ae0956f..81b039ac 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -70,6 +70,9 @@ class MeshDelegateHandler( } else { // Public message messageManager.addMessage(message) + + // Check for mentions in mesh chat + checkAndTriggerMeshMentionNotification(message) } // Periodic cleanup @@ -124,6 +127,48 @@ class MeshDelegateHandler( return privateChatManager.isFavorite(peerID) } + /** + * Check for mentions in mesh messages and trigger notifications + */ + private fun checkAndTriggerMeshMentionNotification(message: BitchatMessage) { + try { + // Get user's current nickname + val currentNickname = state.getNicknameValue() + if (currentNickname.isNullOrEmpty()) { + return + } + + // Check if this message mentions the current user using @username format + val isMention = checkForMeshMention(message.content, currentNickname) + + if (isMention) { + android.util.Log.d("MeshDelegateHandler", "🔔 Triggering mesh mention notification from ${message.sender}") + + notificationManager.showMeshMentionNotification( + senderNickname = message.sender, + messageContent = message.content, + senderPeerID = message.senderPeerID + ) + } + } catch (e: Exception) { + android.util.Log.e("MeshDelegateHandler", "Error checking mesh mentions: ${e.message}") + } + } + + /** + * Check if the content mentions the current user with @username format (simple, no hash suffix) + */ + private fun checkForMeshMention(content: String, currentNickname: String): Boolean { + // Simple mention pattern for mesh: @username (no hash suffix like geohash) + val mentionPattern = "@([\\p{L}0-9_]+)".toRegex() + + return mentionPattern.findAll(content).any { match -> + val mentionedUsername = match.groupValues[1] + // Direct comparison for mesh mentions (no hash suffix to remove) + mentionedUsername.equals(currentNickname, ignoreCase = true) + } + } + /** * Send read receipts reactively based on UI focus state. * Uses same logic as notification system - send read receipt if user is currently 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 42028fff..79c654ee 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -15,10 +15,12 @@ import com.bitchat.android.R import java.util.concurrent.ConcurrentHashMap /** - * Enhanced notification manager for direct messages with production-ready features: - * - Notification grouping per sender - * - Click handling to open specific DM + * Enhanced notification manager for direct messages and geohash chats with production-ready features: + * - Notification grouping per sender/geohash + * - Click handling to open specific DM or geohash chat * - App background state awareness + * - Support for mention notifications in geohash chats + * - Support for first message notifications in geohash chats * - Proper notification management and cleanup */ class NotificationManager(private val context: Context) { @@ -26,14 +28,20 @@ class NotificationManager(private val context: Context) { companion object { private const val TAG = "NotificationManager" private const val CHANNEL_ID = "bitchat_dm_notifications" + private const val GEOHASH_CHANNEL_ID = "bitchat_geohash_notifications" private const val GROUP_KEY_DM = "bitchat_dm_group" + private const val GROUP_KEY_GEOHASH = "bitchat_geohash_group" private const val NOTIFICATION_REQUEST_CODE = 1000 + private const val GEOHASH_NOTIFICATION_REQUEST_CODE = 2000 private const val SUMMARY_NOTIFICATION_ID = 999 + private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998 // Intent extras for notification handling const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat" + const val EXTRA_OPEN_GEOHASH_CHAT = "open_geohash_chat" const val EXTRA_PEER_ID = "peer_id" const val EXTRA_SENDER_NICKNAME = "sender_nickname" + const val EXTRA_GEOHASH = "geohash" } private val notificationManager = NotificationManagerCompat.from(context) @@ -41,6 +49,7 @@ class NotificationManager(private val context: Context) { // Track pending notifications per sender to enable grouping private val pendingNotifications = ConcurrentHashMap>() + private val pendingGeohashNotifications = ConcurrentHashMap>() // Track app background state @Volatile @@ -49,6 +58,9 @@ class NotificationManager(private val context: Context) { // Track current view state @Volatile private var currentPrivateChatPeer: String? = null + + @Volatile + private var currentGeohash: String? = null data class PendingNotification( val senderPeerID: String, @@ -56,6 +68,15 @@ class NotificationManager(private val context: Context) { val messageContent: String, val timestamp: Long ) + + data class GeohashNotification( + val geohash: String, + val senderNickname: String, + val messageContent: String, + val timestamp: Long, + val isMention: Boolean, + val isFirstMessage: Boolean + ) init { createNotificationChannel() @@ -63,15 +84,27 @@ class NotificationManager(private val context: Context) { private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = "Direct Messages" - val descriptionText = "Notifications for private messages from other users" - val importance = NotificationManager.IMPORTANCE_HIGH - val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { - description = descriptionText + // DM notifications channel + val dmName = "Direct Messages" + val dmDescriptionText = "Notifications for private messages from other users" + val dmImportance = NotificationManager.IMPORTANCE_HIGH + val dmChannel = NotificationChannel(CHANNEL_ID, dmName, dmImportance).apply { + description = dmDescriptionText enableVibration(true) setShowBadge(true) } - systemNotificationManager.createNotificationChannel(channel) + systemNotificationManager.createNotificationChannel(dmChannel) + + // Geohash notifications channel + val geohashName = "Geohash Chats" + val geohashDescriptionText = "Notifications for mentions and messages in geohash location channels" + val geohashImportance = NotificationManager.IMPORTANCE_HIGH + val geohashChannel = NotificationChannel(GEOHASH_CHANNEL_ID, geohashName, geohashImportance).apply { + description = geohashDescriptionText + enableVibration(true) + setShowBadge(true) + } + systemNotificationManager.createNotificationChannel(geohashChannel) } } @@ -90,6 +123,14 @@ class NotificationManager(private val context: Context) { currentPrivateChatPeer = peerID Log.d(TAG, "Current private chat peer changed: $peerID") } + + /** + * Update current geohash - affects notification logic for geohash chats + */ + fun setCurrentGeohash(geohash: String?) { + currentGeohash = geohash + Log.d(TAG, "Current geohash changed: $geohash") + } /** * Show a notification for a private message with proper grouping and state awareness @@ -290,11 +331,370 @@ class NotificationManager(private val context: Context) { Log.d(TAG, "Cleared notifications for sender: $senderPeerID") } + /** + * Show a notification for a geohash message with mention or first message + */ + fun showGeohashNotification( + geohash: String, + senderNickname: String, + messageContent: String, + isMention: Boolean = false, + isFirstMessage: Boolean = false + ) { + // Only show notifications if app is in background OR user is not viewing this specific geohash + val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash) + + if (!shouldNotify) { + Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash") + return + } + + Log.d(TAG, "Showing geohash notification for $geohash from $senderNickname (mention: $isMention, first: $isFirstMessage)") + + val notification = GeohashNotification( + geohash = geohash, + senderNickname = senderNickname, + messageContent = messageContent, + timestamp = System.currentTimeMillis(), + isMention = isMention, + isFirstMessage = isFirstMessage + ) + + // Add to pending notifications for this geohash + pendingGeohashNotifications.computeIfAbsent(geohash) { mutableListOf() }.add(notification) + + // Create or update notification for this geohash + showNotificationForGeohash(geohash) + + // Update summary notification if we have multiple geohashes + if (pendingGeohashNotifications.size > 1) { + showGeohashSummaryNotification() + } + } + + private fun showNotificationForGeohash(geohash: String) { + val notifications = pendingGeohashNotifications[geohash] ?: return + if (notifications.isEmpty()) return + + val latestNotification = notifications.last() + val messageCount = notifications.size + val mentionCount = notifications.count { it.isMention } + val firstMessageCount = notifications.count { it.isFirstMessage } + + // Create intent to open the specific geohash chat + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_OPEN_GEOHASH_CHAT, true) + putExtra(EXTRA_GEOHASH, geohash) + } + + val pendingIntent = PendingIntent.getActivity( + context, + GEOHASH_NOTIFICATION_REQUEST_CODE + geohash.hashCode(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Build notification content + 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" + } + + val contentText = when { + latestNotification.isMention -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}" + latestNotification.isFirstMessage -> "${latestNotification.senderNickname} joined the conversation" + else -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}" + } + + val builder = NotificationCompat.Builder(context, GEOHASH_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(if (latestNotification.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setShowWhen(true) + .setWhen(latestNotification.timestamp) + + // Add to notification group if we have multiple geohashes + if (pendingGeohashNotifications.size > 1) { + builder.setGroup(GROUP_KEY_GEOHASH) + } + + // Add style for multiple messages + if (messageCount > 1) { + val style = NotificationCompat.InboxStyle() + .setBigContentTitle(contentTitle) + + // Show last few messages in expanded view + notifications.takeLast(5).forEach { notif -> + val prefix = when { + notif.isMention -> "💬 " + notif.isFirstMessage -> "👋 " + else -> "" + } + style.addLine("$prefix${notif.senderNickname}: ${notif.messageContent}") + } + + if (messageCount > 5) { + style.setSummaryText("and ${messageCount - 5} more") + } + + builder.setStyle(style) + } else { + // Single message - use BigTextStyle for long messages + builder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(contentText) + ) + } + + // Use geohash hash as notification ID to group messages from same geohash + val notificationId = 3000 + geohash.hashCode() + notificationManager.notify(notificationId, builder.build()) + + Log.d(TAG, "Displayed geohash notification for $contentTitle with ID $notificationId") + } + + private fun showGeohashSummaryNotification() { + if (pendingGeohashNotifications.isEmpty()) return + + val totalMessages = pendingGeohashNotifications.values.sumOf { it.size } + val geohashCount = pendingGeohashNotifications.size + val totalMentions = pendingGeohashNotifications.values.sumOf { notifications -> + notifications.count { it.isMention } + } + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + GEOHASH_NOTIFICATION_REQUEST_CODE, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val contentTitle = if (totalMentions > 0) { + "bitchat - $totalMentions mentions" + } else { + "bitchat - location chats" + } + + val contentText = "$totalMessages messages from $geohashCount locations" + + val builder = NotificationCompat.Builder(context, GEOHASH_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(GROUP_KEY_GEOHASH) + .setGroupSummary(true) + + // Add inbox style showing recent geohashes + val style = NotificationCompat.InboxStyle() + .setBigContentTitle("New Location Messages") + + pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) -> + val mentionCount = notifications.count { it.isMention } + val messageCount = notifications.size + val line = when { + mentionCount > 0 -> "#$geohash: $mentionCount mentions (+${messageCount - mentionCount} more)" + messageCount == 1 -> "#$geohash: 1 message" + else -> "#$geohash: $messageCount messages" + } + style.addLine(line) + } + + if (pendingGeohashNotifications.size > 5) { + style.setSummaryText("and ${pendingGeohashNotifications.size - 5} more locations") + } + + builder.setStyle(style) + + notificationManager.notify(GEOHASH_SUMMARY_NOTIFICATION_ID, builder.build()) + + Log.d(TAG, "Displayed geohash summary notification for $geohashCount locations") + } + + /** + * Clear notifications for a specific geohash (e.g., when user opens that chat) + */ + fun clearNotificationsForGeohash(geohash: String) { + pendingGeohashNotifications.remove(geohash) + + // Cancel the individual notification + val notificationId = 3000 + geohash.hashCode() + notificationManager.cancel(notificationId) + + // Update or remove summary notification + if (pendingGeohashNotifications.isEmpty()) { + notificationManager.cancel(GEOHASH_SUMMARY_NOTIFICATION_ID) + } else if (pendingGeohashNotifications.size == 1) { + // Only one geohash left, remove group summary + notificationManager.cancel(GEOHASH_SUMMARY_NOTIFICATION_ID) + } else { + // Update summary notification + showGeohashSummaryNotification() + } + + Log.d(TAG, "Cleared notifications for geohash: $geohash") + } + + /** + * Show a notification for a mesh mention (@username format) + */ + fun showMeshMentionNotification( + senderNickname: String, + messageContent: String, + senderPeerID: String? = null + ) { + // Only show notifications if app is in background OR user is not viewing mesh chat + // User is viewing mesh chat when: not in private chat AND not in geohash chat + val isViewingMeshChat = currentPrivateChatPeer == null && currentGeohash == null + val shouldNotify = isAppInBackground || (!isAppInBackground && !isViewingMeshChat) + + if (!shouldNotify) { + Log.d(TAG, "Skipping mesh mention notification - app in foreground and viewing mesh chat") + return + } + + Log.d(TAG, "Showing mesh mention notification from $senderNickname") + + // Use a special key for mesh mentions to group them together + val meshMentionKey = "mesh_mentions" + val notification = PendingNotification( + senderPeerID = senderPeerID ?: meshMentionKey, + senderNickname = senderNickname, + messageContent = messageContent, + timestamp = System.currentTimeMillis() + ) + + // Add to pending notifications for mesh mentions + pendingNotifications.computeIfAbsent(meshMentionKey) { mutableListOf() }.add(notification) + + // Create or update notification for mesh mentions + showNotificationForMeshMentions() + + // Update summary notification if we have multiple senders + if (pendingNotifications.size > 1) { + showSummaryNotification() + } + } + + private fun showNotificationForMeshMentions() { + val notifications = pendingNotifications["mesh_mentions"] ?: return + if (notifications.isEmpty()) return + + val latestNotification = notifications.last() + val messageCount = notifications.size + + // Create intent to open the mesh chat (no specific peer, just main chat) + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + // No specific chat to open, just bring the app to foreground + } + + val pendingIntent = PendingIntent.getActivity( + context, + NOTIFICATION_REQUEST_CODE + "mesh_mentions".hashCode(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Build notification content + val contentTitle = if (messageCount == 1) { + "Mentioned in Mesh Chat" + } else { + "$messageCount mentions in Mesh Chat" + } + + val contentText = "${latestNotification.senderNickname}: ${latestNotification.messageContent}" + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setShowWhen(true) + .setWhen(latestNotification.timestamp) + + // Add to notification group if we have multiple senders + if (pendingNotifications.size > 1) { + builder.setGroup(GROUP_KEY_DM) + } + + // Add style for multiple messages + if (messageCount > 1) { + val style = NotificationCompat.InboxStyle() + .setBigContentTitle(contentTitle) + + // Show last few messages in expanded view + notifications.takeLast(5).forEach { notif -> + style.addLine("${notif.senderNickname}: ${notif.messageContent}") + } + + if (messageCount > 5) { + style.setSummaryText("and ${messageCount - 5} more") + } + + builder.setStyle(style) + } else { + // Single message - use BigTextStyle for long messages + builder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(contentText) + ) + } + + // Use a special notification ID for mesh mentions + val notificationId = 4000 // Different from DM and geohash IDs + notificationManager.notify(notificationId, builder.build()) + + Log.d(TAG, "Displayed mesh mention notification: $contentTitle") + } + + /** + * Clear mesh mention notifications (when user opens mesh chat) + */ + fun clearMeshMentionNotifications() { + pendingNotifications.remove("mesh_mentions") + + // Cancel the mesh mention notification + val notificationId = 4000 + notificationManager.cancel(notificationId) + + // Update or remove summary notification + if (pendingNotifications.isEmpty()) { + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } else if (pendingNotifications.size == 1) { + // Only one sender left, remove group summary + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } else { + // Update summary notification + showSummaryNotification() + } + + Log.d(TAG, "Cleared mesh mention notifications") + } + /** * Clear all pending notifications */ fun clearAllNotifications() { pendingNotifications.clear() + pendingGeohashNotifications.clear() notificationManager.cancelAll() Log.d(TAG, "Cleared all notifications") } @@ -303,7 +703,8 @@ class NotificationManager(private val context: Context) { * Get pending notification count for UI badging */ fun getPendingNotificationCount(): Int { - return pendingNotifications.values.sumOf { it.size } + return pendingNotifications.values.sumOf { it.size } + + pendingGeohashNotifications.values.sumOf { it.size } } /** @@ -328,10 +729,17 @@ class NotificationManager(private val context: Context) { appendLine("Notification Manager Debug Info:") appendLine("App in background: $isAppInBackground") appendLine("Current private chat peer: $currentPrivateChatPeer") - appendLine("Pending notifications: ${pendingNotifications.size} senders") + appendLine("Current geohash: $currentGeohash") + appendLine("Pending DM notifications: ${pendingNotifications.size} senders") pendingNotifications.forEach { (peerID, notifications) -> appendLine(" $peerID: ${notifications.size} messages") } + appendLine("Pending geohash notifications: ${pendingGeohashNotifications.size} geohashes") + pendingGeohashNotifications.forEach { (geohash, notifications) -> + val mentions = notifications.count { it.isMention } + val firstMessages = notifications.count { it.isFirstMessage } + appendLine(" #$geohash: ${notifications.size} messages ($mentions mentions, $firstMessages first messages)") + } } } }