diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 4d7091e6..bf990d64 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -1,6 +1,7 @@ package com.bitchat.android import android.Manifest +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -44,6 +45,9 @@ class MainActivity : ComponentActivity() { // Request necessary permissions requestPermissions() + // Handle notification intent if app was opened from a notification + handleNotificationIntent(intent) + setContent { BitchatTheme { Surface( @@ -56,18 +60,49 @@ class MainActivity : ComponentActivity() { } } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + // Handle notification intents when app is already running + intent?.let { handleNotificationIntent(it) } + } + override fun onResume() { super.onResume() - // Notify that app is in foreground for power optimization + // Notify that app is in foreground for power optimization and notifications chatViewModel.setAppBackgroundState(false) } override fun onPause() { super.onPause() - // Notify that app is in background for power optimization + // Notify that app is in background for power optimization and notifications chatViewModel.setAppBackgroundState(true) } + /** + * Handle intents from notification clicks - open specific private chat + */ + private fun handleNotificationIntent(intent: Intent) { + val shouldOpenPrivateChat = intent.getBooleanExtra( + com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_PRIVATE_CHAT, + 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) + + if (peerID != null) { + android.util.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) + } + } + } + private fun requestPermissions() { val permissions = mutableListOf() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index eec65c24..e76dba4c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -6,7 +6,8 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState @@ -78,24 +79,31 @@ fun PeerCounter( Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier.clickable { onClick() } + modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing ) { if (hasUnreadChannels.values.any { it > 0 }) { - Text( - text = "#", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF0080FF), - fontSize = 16.sp - ) + // Channel icon in a Box to ensure consistent size with other icons + Box( + modifier = Modifier.size(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "#", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF0080FF), + fontSize = 16.sp + ) + } Spacer(modifier = Modifier.width(6.dp)) } if (hasUnreadPrivateMessages.isNotEmpty()) { - Text( - text = "✉", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFFFF8C00), - fontSize = 16.sp + // Filled mail icon to match sidebar style + Icon( + imageVector = Icons.Filled.Email, + contentDescription = "Unread private messages", + modifier = Modifier.size(16.dp), + tint = Color(0xFFFF8C00) // Orange to match private message theme ) Spacer(modifier = Modifier.width(6.dp)) } @@ -198,18 +206,42 @@ private fun PrivateChatHeader( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = onBackClick) { - Text( - text = "← back", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) + // Fixed: Make back button wider to prevent text cropping + Button( + onClick = onBackClick, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = colorScheme.primary + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "back", + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.primary + ) + } } Spacer(modifier = Modifier.weight(1f)) Row(verticalAlignment = Alignment.CenterVertically) { - Text("🔒", fontSize = 16.sp) // Slightly larger + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = "Private chat", + modifier = Modifier.size(16.dp), + tint = Color(0xFFFF8C00) // Orange to match private message theme + ) Spacer(modifier = Modifier.width(4.dp)) Text( text = peerNickname, @@ -220,12 +252,13 @@ private fun PrivateChatHeader( Spacer(modifier = Modifier.weight(1f)) - // Favorite button + // Favorite button with proper filled/outlined star IconButton(onClick = onToggleFavorite) { - Text( - text = if (isFavorite) "★" else "☆", - color = if (isFavorite) Color.Yellow else colorScheme.primary, - fontSize = 18.sp // Larger icon + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon + tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) // Yellow for filled, green for outlined ) } } @@ -246,11 +279,22 @@ private fun ChannelHeader( verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onBackClick) { - Text( - text = "← back", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.primary - ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "back", + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.primary + ) + } } Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 394ffbe8..9dc16cf6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -78,6 +78,12 @@ class ChatState { private val _commandSuggestions = MutableLiveData>(emptyList()) val commandSuggestions: LiveData> = _commandSuggestions + // Favorites + private val _favoritePeers = MutableLiveData>(emptySet()) + val favoritePeers: LiveData> = _favoritePeers + + val peerIDToPublicKeyFingerprint = mutableMapOf() + // Unread state computed properties val hasUnreadChannels: MediatorLiveData = MediatorLiveData() val hasUnreadPrivateMessages: MediatorLiveData = MediatorLiveData() @@ -110,6 +116,7 @@ class ChatState { fun getShowSidebarValue() = _showSidebar.value ?: false fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() + fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() // Setters for state updates fun setMessages(messages: List) { @@ -179,4 +186,9 @@ class ChatState { fun setCommandSuggestions(suggestions: List) { _commandSuggestions.value = suggestions } + + fun setFavoritePeers(favorites: Set) { + _favoritePeers.value = favorites + } + } 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 4cfb92c3..7d9a5055 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -33,8 +33,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B private val dataManager = DataManager(context) private val messageManager = MessageManager(state) private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope) - private val privateChatManager = PrivateChatManager(state, messageManager, dataManager) + val privateChatManager = PrivateChatManager(state, messageManager, dataManager) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) + private val notificationManager = NotificationManager(application.applicationContext) // Delegate handler for mesh callbacks private val meshDelegateHandler = MeshDelegateHandler( @@ -42,6 +43,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B messageManager = messageManager, channelManager = channelManager, privateChatManager = privateChatManager, + notificationManager = notificationManager, coroutineScope = viewModelScope, onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(context) }, getMyPeerID = { meshService.myPeerID } @@ -67,6 +69,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages val showCommandSuggestions: LiveData = state.showCommandSuggestions val commandSuggestions: LiveData> = state.commandSuggestions + val favoritePeers: LiveData> = state.favoritePeers init { meshService.delegate = this @@ -94,6 +97,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B // Load other data dataManager.loadFavorites() + state.setFavoritePeers(dataManager.favoritePeers) dataManager.loadBlockedUsers() // Start mesh service @@ -145,11 +149,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B // MARK: - Private Chat Management (delegated) fun startPrivateChat(peerID: String) { - privateChatManager.startPrivateChat(peerID, meshService) + val success = privateChatManager.startPrivateChat(peerID, meshService) + if (success) { + // Notify notification manager about current private chat + setCurrentPrivateChatPeer(peerID) + // Clear notifications for this sender since user is now viewing the chat + clearNotificationsForSender(peerID) + } } fun endPrivateChat() { privateChatManager.endPrivateChat() + // Notify notification manager that no private chat is active + setCurrentPrivateChatPeer(null) } // MARK: - Message Sending @@ -262,6 +274,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B fun setAppBackgroundState(inBackground: Boolean) { // Forward to connection manager for power optimization meshService.connectionManager.setAppBackgroundState(inBackground) + + // Forward to notification manager for notification logic + notificationManager.setAppBackgroundState(inBackground) + } + + fun setCurrentPrivateChatPeer(peerID: String?) { + // Update notification manager with current private chat peer + notificationManager.setCurrentPrivateChatPeer(peerID) + } + + fun clearNotificationsForSender(peerID: String) { + // Clear notifications when user opens a chat + notificationManager.clearNotificationsForSender(peerID) } // MARK: - Command Autocomplete (delegated) diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index b9ffb8e1..cc25dc4f 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -42,11 +44,11 @@ fun MessageInput( modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding verticalAlignment = Alignment.CenterVertically ) { - // Prompt + // Fixed: Remove arrow from private message input Text( text = when { - selectedPrivatePeer != null -> "<@$nickname> →" - currentChannel != null -> "<@$nickname> →" // Could show if channel is encrypted + selectedPrivatePeer != null -> "<@$nickname>" // Removed arrow for private + currentChannel != null -> "<@$nickname> →" // Keep arrow for channels else -> "<@$nickname>" }, style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium), @@ -76,7 +78,7 @@ fun MessageInput( Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing - // Send button - solid bright green background with thick transparent arrow + // Fixed: Make send button orange in private mode to match nickname color IconButton( onClick = onSend, modifier = Modifier.size(32.dp) @@ -85,7 +87,10 @@ fun MessageInput( modifier = Modifier .size(30.dp) .background( - color = if (colorScheme.background == Color.Black) { + color = if (selectedPrivatePeer != null) { + // Orange for private messages to match nickname color + Color(0xFFFF8C00).copy(alpha = 0.75f) + } else if (colorScheme.background == Color.Black) { Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme } else { Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme @@ -94,17 +99,18 @@ fun MessageInput( ), contentAlignment = Alignment.Center ) { - Text( - text = "↑", - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 20.sp, - fontWeight = FontWeight.Bold - ), - color = if (colorScheme.background == Color.Black) { + Icon( + imageVector = Icons.Filled.KeyboardArrowUp, + contentDescription = "Send message", + modifier = Modifier.size(20.dp), + tint = if (selectedPrivatePeer != null) { + // Black arrow on orange in private mode + Color.Black + } else if (colorScheme.background == Color.Black) { Color.Black // Black arrow on bright green in dark theme } else { Color.White // White arrow on dark green in light theme - }, + } ) } } 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 ee656059..873d9c56 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -18,11 +18,12 @@ class MeshDelegateHandler( private val messageManager: MessageManager, private val channelManager: ChannelManager, private val privateChatManager: PrivateChatManager, + private val notificationManager: NotificationManager, private val coroutineScope: CoroutineScope, private val onHapticFeedback: () -> Unit, private val getMyPeerID: () -> String ) : BluetoothMeshDelegate { - + override fun didReceiveMessage(message: BitchatMessage) { coroutineScope.launch { // FIXED: Deduplicate messages from dual connection paths @@ -45,6 +46,19 @@ class MeshDelegateHandler( if (message.isPrivate) { // Private message privateChatManager.handleIncomingPrivateMessage(message) + + // Show notification with enhanced information - now includes senderPeerID + message.senderPeerID?.let { senderPeerID -> + if (state.getSelectedPrivateChatPeerValue() != senderPeerID) { + // Use nickname if available, fall back to sender or senderPeerID + val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID + notificationManager.showPrivateMessageNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = message.content + ) + } + } } else if (message.channel != null) { // Channel message if (state.getJoinedChannelsValue().contains(message.channel)) { diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt new file mode 100644 index 00000000..ce7e1e8a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -0,0 +1,323 @@ +package com.bitchat.android.ui + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.MainActivity +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 + * - App background state awareness + * - Proper notification management and cleanup + */ +class NotificationManager(private val context: Context) { + + companion object { + private const val TAG = "NotificationManager" + private const val CHANNEL_ID = "bitchat_dm_notifications" + private const val GROUP_KEY_DM = "bitchat_dm_group" + private const val NOTIFICATION_REQUEST_CODE = 1000 + private const val SUMMARY_NOTIFICATION_ID = 999 + + // Intent extras for notification handling + const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat" + const val EXTRA_PEER_ID = "peer_id" + const val EXTRA_SENDER_NICKNAME = "sender_nickname" + } + + private val notificationManager = NotificationManagerCompat.from(context) + private val systemNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Track pending notifications per sender to enable grouping + private val pendingNotifications = ConcurrentHashMap>() + + // Track app background state + @Volatile + private var isAppInBackground = false + + // Track current view state + @Volatile + private var currentPrivateChatPeer: String? = null + + data class PendingNotification( + val senderPeerID: String, + val senderNickname: String, + val messageContent: String, + val timestamp: Long + ) + + init { + createNotificationChannel() + } + + 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 + enableVibration(true) + setShowBadge(true) + } + systemNotificationManager.createNotificationChannel(channel) + } + } + + /** + * Update app background state - notifications should only be shown when app is backgrounded + */ + fun setAppBackgroundState(inBackground: Boolean) { + isAppInBackground = inBackground + Log.d(TAG, "App background state changed: $inBackground") + } + + /** + * Update current private chat peer - affects notification logic + */ + fun setCurrentPrivateChatPeer(peerID: String?) { + currentPrivateChatPeer = peerID + Log.d(TAG, "Current private chat peer changed: $peerID") + } + + /** + * Show a notification for a private message with proper grouping and state awareness + */ + fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) { + // Only show notifications if app is in background OR user is not viewing this specific chat + val shouldNotify = isAppInBackground || currentPrivateChatPeer != senderPeerID + + if (!shouldNotify) { + Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname") + return + } + + Log.d(TAG, "Showing notification for message from $senderNickname (peerID: $senderPeerID)") + + val notification = PendingNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = messageContent, + timestamp = System.currentTimeMillis() + ) + + // Add to pending notifications for this sender + pendingNotifications.computeIfAbsent(senderPeerID) { mutableListOf() }.add(notification) + + // Create or update notification for this sender + showNotificationForSender(senderPeerID) + + // Update summary notification if we have multiple senders + if (pendingNotifications.size > 1) { + showSummaryNotification() + } + } + + private fun showNotificationForSender(senderPeerID: String) { + val notifications = pendingNotifications[senderPeerID] ?: return + if (notifications.isEmpty()) return + + val latestNotification = notifications.last() + val messageCount = notifications.size + + // Create intent to open the specific private chat + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_OPEN_PRIVATE_CHAT, true) + putExtra(EXTRA_PEER_ID, senderPeerID) + putExtra(EXTRA_SENDER_NICKNAME, latestNotification.senderNickname) + } + + val pendingIntent = PendingIntent.getActivity( + context, + NOTIFICATION_REQUEST_CODE + senderPeerID.hashCode(), + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + // Create person for better notification styling + val person = Person.Builder() + .setName(latestNotification.senderNickname) + .setKey(senderPeerID) + .build() + + // Build notification content + val contentText = if (messageCount == 1) { + latestNotification.messageContent + } else { + "${latestNotification.messageContent} (+${messageCount - 1} more)" + } + + val contentTitle = if (messageCount == 1) { + latestNotification.senderNickname + } else { + "${latestNotification.senderNickname} ($messageCount messages)" + } + + 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) + .addPerson(person) + .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.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(latestNotification.messageContent) + ) + } + + // Use sender peer ID hash as notification ID to group messages from same sender + val notificationId = senderPeerID.hashCode() + notificationManager.notify(notificationId, builder.build()) + + Log.d(TAG, "Displayed notification for $contentTitle with ID $notificationId") + } + + private fun showSummaryNotification() { + if (pendingNotifications.isEmpty()) return + + val totalMessages = pendingNotifications.values.sumOf { it.size } + val senderCount = pendingNotifications.size + + 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, + NOTIFICATION_REQUEST_CODE, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("bitchat") + .setContentText("$totalMessages messages from $senderCount people") + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(GROUP_KEY_DM) + .setGroupSummary(true) + + // Add inbox style showing recent senders + val style = NotificationCompat.InboxStyle() + .setBigContentTitle("New Messages") + + pendingNotifications.entries.take(5).forEach { (peerID, notifications) -> + val latestNotif = notifications.last() + val count = notifications.size + val line = if (count == 1) { + "${latestNotif.senderNickname}: ${latestNotif.messageContent}" + } else { + "${latestNotif.senderNickname}: $count messages" + } + style.addLine(line) + } + + if (pendingNotifications.size > 5) { + style.setSummaryText("and ${pendingNotifications.size - 5} more conversations") + } + + builder.setStyle(style) + + notificationManager.notify(SUMMARY_NOTIFICATION_ID, builder.build()) + + Log.d(TAG, "Displayed summary notification for $senderCount senders") + } + + /** + * Clear notifications for a specific sender (e.g., when user opens their chat) + */ + fun clearNotificationsForSender(senderPeerID: String) { + pendingNotifications.remove(senderPeerID) + + // Cancel the individual notification + val notificationId = senderPeerID.hashCode() + 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 notifications for sender: $senderPeerID") + } + + /** + * Clear all pending notifications + */ + fun clearAllNotifications() { + pendingNotifications.clear() + notificationManager.cancelAll() + Log.d(TAG, "Cleared all notifications") + } + + /** + * Get pending notification count for UI badging + */ + fun getPendingNotificationCount(): Int { + return pendingNotifications.values.sumOf { it.size } + } + + /** + * Get pending notifications for debugging + */ + fun getDebugInfo(): String { + return buildString { + appendLine("Notification Manager Debug Info:") + appendLine("App in background: $isAppInBackground") + appendLine("Current private chat peer: $currentPrivateChatPeer") + appendLine("Pending notifications: ${pendingNotifications.size} senders") + pendingNotifications.forEach { (peerID, notifications) -> + appendLine(" $peerID: ${notifications.size} messages") + } + } + } +} 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 c9341f04..cb94ab4e 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -104,6 +104,7 @@ class PrivateChatManager( } else { dataManager.addFavorite(fingerprint) } + state.setFavoritePeers(dataManager.favoritePeers) } fun isFavorite(peerID: String): Boolean { diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 4d63d33d..e97b6446 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState @@ -17,6 +18,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex /** @@ -256,21 +258,21 @@ fun PeopleSection( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) ) } else { - // Sort peers: favorites first, then by nickname - val sortedPeers = connectedPeers.sortedWith { peer1, peer2 -> - val isFav1 = viewModel.isFavorite(peer1) - val isFav2 = viewModel.isFavorite(peer2) - - when { - isFav1 && !isFav2 -> -1 - !isFav1 && isFav2 -> 1 - else -> { - val name1 = if (peer1 == nickname) "You" else (peerNicknames[peer1] ?: peer1) - val name2 = if (peer2 == nickname) "You" else (peerNicknames[peer2] ?: peer2) - name1.compareTo(name2, ignoreCase = true) - } - } - } + // Get unread private messages and private chat history for sorting + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) + val privateChats by viewModel.privateChats.observeAsState(emptyMap()) + val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) + + // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical + val sortedPeers = connectedPeers.sortedWith( + compareBy { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first + .thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long) + .thenBy { + val fingerprint = viewModel.privateChatManager.getPeerFingerprint(it) + fingerprint == null || !favoritePeers.contains(fingerprint) + } // Favorites + .thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical + ) sortedPeers.forEach { peerID -> PeerItem( @@ -278,7 +280,8 @@ fun PeopleSection( displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID), signalStrength = peerRSSI[peerID] ?: 0, isSelected = peerID == selectedPrivatePeer, - isFavorite = viewModel.isFavorite(peerID), + isFavorite = favoritePeers.contains(viewModel.privateChatManager.getPeerFingerprint(peerID)), + hasUnreadDM = hasUnreadPrivateMessages.contains(peerID), colorScheme = colorScheme, onItemClick = { onPrivateChatStart(peerID) }, onToggleFavorite = { viewModel.toggleFavorite(peerID) } @@ -295,6 +298,7 @@ private fun PeerItem( signalStrength: Int, isSelected: Boolean, isFavorite: Boolean, + hasUnreadDM: Boolean, colorScheme: ColorScheme, onItemClick: () -> Unit, onToggleFavorite: () -> Unit @@ -310,11 +314,21 @@ private fun PeerItem( .padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - // Signal strength indicators - SignalStrengthIndicator( - signalStrength = signalStrength, - colorScheme = colorScheme - ) + // Show filled mail icon instead of signal strength when user has unread DMs + if (hasUnreadDM) { + Icon( + imageVector = Icons.Filled.Email, + contentDescription = "Unread messages", + modifier = Modifier.size(16.dp), + tint = Color(0xFFFF8C00) // Orange to match private message theme + ) + } else { + // Signal strength indicators + SignalStrengthIndicator( + signalStrength = signalStrength, + colorScheme = colorScheme + ) + } Spacer(modifier = Modifier.width(8.dp)) @@ -326,16 +340,16 @@ private fun PeerItem( modifier = Modifier.weight(1f) ) - // Favorite star + // Favorite star with proper filled/outlined states IconButton( onClick = onToggleFavorite, modifier = Modifier.size(24.dp) ) { Icon( - imageVector = Icons.Default.Star, + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", modifier = Modifier.size(16.dp), - tint = if (isFavorite) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f) + tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.primary ) } }