mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
parent
90b00ac557
commit
5fd686a00a
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user