diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 432d9a1e..2e0507ab 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -482,7 +482,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic delegate?.didReceiveMessage(message) // If no UI delegate attached (app closed), show DM notification via service manager - if (delegate == null && message.isPrivate) { + if (delegate == null && message.isPrivate && message.sender != "system") { try { val senderPeerID = message.senderPeerID if (senderPeerID != null) { 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 d3356e85..b0f75a94 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -575,13 +575,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro } val action = if (control.isFavorite) "favorited" else "unfavorited" + val notice = "${peerInfo.nickname} $action you$guidance" val sys = com.bitchat.android.model.BitchatMessage( sender = "system", - content = "${peerInfo.nickname} $action you$guidance", + content = notice, timestamp = java.util.Date(), isRelay = false ) delegate?.onMessageReceived(sys) + + // Mirror the notice into the private conversation so it's visible while chatting + try { + val conversationID = com.bitchat.android.services.ContactDirectory + .canonicalConversationId(fromPeerID) + val sysPrivate = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = notice, + timestamp = java.util.Date(), + isRelay = false, + isPrivate = true, + senderPeerID = conversationID + ) + delegate?.onMessageReceived(sysPrivate) + } catch (_: Exception) { + // Best-effort; public notice already delivered + } } } catch (_: Exception) { // Best-effort; ignore errors 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 53a298ef..13c754a8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -94,6 +94,10 @@ class ChatState( // Favorites private val _favoritePeers = MutableStateFlow>(emptySet()) val favoritePeers: StateFlow> = _favoritePeers.asStateFlow() + + // Fingerprints of peers who favorited us (drives "favorited you" UI celebrations) + private val _peerFavoritedUs = MutableStateFlow>(emptySet()) + val peerFavoritedUs: StateFlow> = _peerFavoritedUs.asStateFlow() // Noise session states for peers (for reactive UI updates) private val _peerSessionStates = MutableStateFlow>(emptyMap()) @@ -174,6 +178,7 @@ class ChatState( fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value fun getJoinedChannelsValue() = _joinedChannels.value + fun getPeerFavoritedUsValue() = _peerFavoritedUs.value fun getCurrentChannelValue() = _currentChannel.value fun getChannelMessagesValue() = _channelMessages.value fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value @@ -285,6 +290,10 @@ class ChatState( Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}") } + + fun setPeerFavoritedUs(fingerprints: Set) { + _peerFavoritedUs.value = fingerprints + } fun setPeerSessionStates(states: Map) { _peerSessionStates.value = states 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 d0358a78..eb9e8ee5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -185,6 +185,7 @@ class ChatViewModel( val showMentionSuggestions: StateFlow = state.showMentionSuggestions val mentionSuggestions: StateFlow> = state.mentionSuggestions val favoritePeers: StateFlow> = state.favoritePeers + val peerFavoritedUs: StateFlow> = state.peerFavoritedUs val peerSessionStates: StateFlow> = state.peerSessionStates val peerFingerprints: StateFlow> = state.peerFingerprints val peerNicknames: StateFlow> = state.peerNicknames @@ -243,7 +244,7 @@ class ChatViewModel( val myNick = state.getNicknameValue() ?: mesh.myPeerID val unread = mutableSetOf() canonicalChats.forEach { (peer, list) -> - if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) + if (list.any { msg -> msg.sender != myNick && msg.sender != "system" && !seen.hasRead(msg.id) }) unread.add(peer) } state.setUnreadPrivateMessages(unread) } catch (_: Exception) { } @@ -324,6 +325,17 @@ class ChatViewModel( // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + // Reflect "they favorited us" changes into reactive UI state (drives star celebrations) + refreshPeerFavoritedUs() + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener( + object : com.bitchat.android.favorites.FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs() + override fun onAllCleared() = refreshPeerFavoritedUs() + } + ) + } catch (_: Exception) { } + // Load verified fingerprints from secure storage verificationHandler.loadVerifiedFingerprints() @@ -630,8 +642,22 @@ class ChatViewModel( logCurrentFavoriteState() } - private fun logCurrentFavoriteState() { - Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") + private fun refreshPeerFavoritedUs() { + try { + val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared + .getAllRelationships() + .filter { it.theyFavoritedUs } + .mapNotNull { relationship -> + runCatching { + ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey) + }.getOrNull() + } + .toSet() + state.setPeerFavoritedUs(fingerprints) + } catch (_: Exception) { } + } + + private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") 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 8d18563a..f98c5c87 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -44,24 +44,29 @@ class MeshDelegateHandler( onHapticFeedback() if (message.isPrivate) { - // Private message - privateChatManager.handleIncomingPrivateMessage(message) + if (message.sender == "system") { + // System notices (e.g. "x favorited you"): no unread badge, read receipt or push + privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = true) + } else { + // Private message + privateChatManager.handleIncomingPrivateMessage(message) - // Reactive read receipts: if chat is focused, send immediately for this message - message.senderPeerID?.let { senderPeerID -> - sendReadReceiptIfFocused(message) - } - - // Show notification with enhanced information - now includes senderPeerID - message.senderPeerID?.let { senderPeerID -> - // Use nickname if available, fall back to sender or senderPeerID - val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID - val preview = NotificationTextUtils.buildPrivateMessagePreview(message) - notificationManager.showPrivateMessageNotification( - senderPeerID = senderPeerID, - senderNickname = senderNickname, - messageContent = preview - ) + // Reactive read receipts: if chat is focused, send immediately for this message + message.senderPeerID?.let { senderPeerID -> + sendReadReceiptIfFocused(message) + } + + // Show notification with enhanced information - now includes senderPeerID + message.senderPeerID?.let { senderPeerID -> + // Use nickname if available, fall back to sender or senderPeerID + val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID + val preview = NotificationTextUtils.buildPrivateMessagePreview(message) + notificationManager.showPrivateMessageNotification( + senderPeerID = senderPeerID, + senderNickname = senderNickname, + messageContent = preview + ) + } } } else if (message.channel != null) { // Channel message: AppStateStore is the source of truth for list; only manage unread diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c496938e..21c82b8f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -8,8 +8,12 @@ import com.bitchat.android.ui.theme.BitchatFontFamily import com.bitchat.android.R import android.util.Log import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -27,6 +31,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -54,6 +59,7 @@ import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString +import kotlinx.coroutines.launch /** @@ -352,7 +358,7 @@ fun PeopleSection( // Observe reactive state for favorites and fingerprints val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() - val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() @@ -799,6 +805,7 @@ fun PrivateChatSheet( val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle() val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle() val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle() @@ -815,7 +822,7 @@ fun PrivateChatSheet( } val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") - val favoriteRelationship = remember(peerID, favoritePeers) { + val favoriteRelationship = remember(peerID, favoritePeers, peerFavoritedUs) { try { FavoritesPersistenceService.shared.getFavoriteStatus(peerID) } catch (_: Exception) { @@ -859,12 +866,55 @@ fun PrivateChatSheet( val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) { if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) } + val theyFavoritedUs = remember(peerFavoritedUs, fingerprint, favoriteRelationship) { + (fingerprint != null && peerFavoritedUs.contains(fingerprint)) || + favoriteRelationship?.theyFavoritedUs == true + } + + // Celebrate being favorited: a springy wobble of the header star. Springs rather than + // keyframed tweens, matching the app's press feedback, so the settle overshoots slightly. + val starWobbleRotation = remember { Animatable(0f) } + val starWobbleScale = remember { Animatable(1f) } + var previousTheyFavoritedUs by remember { mutableStateOf(null) } + LaunchedEffect(theyFavoritedUs) { + val wasFavoritedUs = previousTheyFavoritedUs + previousTheyFavoritedUs = theyFavoritedUs + if (theyFavoritedUs && wasFavoritedUs == false) { + starWobbleRotation.snapTo(-16f) + starWobbleScale.snapTo(1.35f) + launch { + starWobbleRotation.animateTo( + targetValue = 0f, + animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessMedium) + ) + } + launch { + starWobbleScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ) + ) + } + } + } val isVerified = remember(peerID, verifiedFingerprints) { viewModel.isPeerVerified(peerID, verifiedFingerprints) } val palette = LocalBitchatPalette.current + // Three-state star: grey outline (no relation), orange outline (they favorited us), + // filled orange (we favorited them, mutual or not). + val favoriteStarTint by animateColorAsState( + targetValue = when { + isFavorite || theyFavoritedUs -> palette.accentOrange + else -> colorScheme.onSurfaceVariant + }, + animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing), + label = "favoriteStarTint" + ) val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true ) @@ -984,12 +1034,14 @@ fun PrivateChatSheet( } ), contentDescription = null, - modifier = Modifier.size(HeaderIconSize), - tint = if (isFavorite) { - palette.accentOrange - } else { - colorScheme.onSurfaceVariant - } + modifier = Modifier + .size(HeaderIconSize) + .graphicsLayer { + rotationZ = starWobbleRotation.value + scaleX = starWobbleScale.value + scaleY = starWobbleScale.value + }, + tint = favoriteStarTint ) }