ui: celebrate being favorited in private chats

- header star wobbles and its outline turns orange when the peer
  favorites you; it only fills once you favorite them back
- mirror the 'favorited you' system notice into the private
  conversation (mesh path), matching the main chat
- expose reactive peerFavoritedUs state driven by
  FavoritesPersistenceService; keep system notices silent
  (no unread badge, read receipt or push)
This commit is contained in:
callebtc 2026-07-27 19:11:37 +02:00
parent 90b00ac557
commit 5fd686a00a
6 changed files with 140 additions and 30 deletions

View File

@ -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) {

View File

@ -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

View File

@ -94,6 +94,10 @@ class ChatState(
// Favorites
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
// Fingerprints of peers who favorited us (drives "favorited you" UI celebrations)
private val _peerFavoritedUs = MutableStateFlow<Set<String>>(emptySet())
val peerFavoritedUs: StateFlow<Set<String>> = _peerFavoritedUs.asStateFlow()
// Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(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<String>) {
_peerFavoritedUs.value = fingerprints
}
fun setPeerSessionStates(states: Map<String, String>) {
_peerSessionStates.value = states

View File

@ -185,6 +185,7 @@ class ChatViewModel(
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerFavoritedUs: StateFlow<Set<String>> = state.peerFavoritedUs
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
@ -243,7 +244,7 @@ class ChatViewModel(
val myNick = state.getNicknameValue() ?: mesh.myPeerID
val unread = mutableSetOf<String>()
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()}")

View File

@ -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

View File

@ -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<Boolean?>(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
)
}