mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +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)
|
delegate?.didReceiveMessage(message)
|
||||||
|
|
||||||
// If no UI delegate attached (app closed), show DM notification via service manager
|
// 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 {
|
try {
|
||||||
val senderPeerID = message.senderPeerID
|
val senderPeerID = message.senderPeerID
|
||||||
if (senderPeerID != null) {
|
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 action = if (control.isFavorite) "favorited" else "unfavorited"
|
||||||
|
val notice = "${peerInfo.nickname} $action you$guidance"
|
||||||
val sys = com.bitchat.android.model.BitchatMessage(
|
val sys = com.bitchat.android.model.BitchatMessage(
|
||||||
sender = "system",
|
sender = "system",
|
||||||
content = "${peerInfo.nickname} $action you$guidance",
|
content = notice,
|
||||||
timestamp = java.util.Date(),
|
timestamp = java.util.Date(),
|
||||||
isRelay = false
|
isRelay = false
|
||||||
)
|
)
|
||||||
delegate?.onMessageReceived(sys)
|
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) {
|
} catch (_: Exception) {
|
||||||
// Best-effort; ignore errors
|
// Best-effort; ignore errors
|
||||||
|
|||||||
@ -94,6 +94,10 @@ class ChatState(
|
|||||||
// Favorites
|
// Favorites
|
||||||
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
|
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
|
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)
|
// Noise session states for peers (for reactive UI updates)
|
||||||
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
|
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
@ -174,6 +178,7 @@ class ChatState(
|
|||||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
|
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
|
||||||
fun getJoinedChannelsValue() = _joinedChannels.value
|
fun getJoinedChannelsValue() = _joinedChannels.value
|
||||||
|
fun getPeerFavoritedUsValue() = _peerFavoritedUs.value
|
||||||
fun getCurrentChannelValue() = _currentChannel.value
|
fun getCurrentChannelValue() = _currentChannel.value
|
||||||
fun getChannelMessagesValue() = _channelMessages.value
|
fun getChannelMessagesValue() = _channelMessages.value
|
||||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
|
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
|
||||||
@ -285,6 +290,10 @@ class ChatState(
|
|||||||
|
|
||||||
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
|
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPeerFavoritedUs(fingerprints: Set<String>) {
|
||||||
|
_peerFavoritedUs.value = fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
fun setPeerSessionStates(states: Map<String, String>) {
|
fun setPeerSessionStates(states: Map<String, String>) {
|
||||||
_peerSessionStates.value = states
|
_peerSessionStates.value = states
|
||||||
|
|||||||
@ -185,6 +185,7 @@ class ChatViewModel(
|
|||||||
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
|
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
|
||||||
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
|
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
|
||||||
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
|
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
|
||||||
|
val peerFavoritedUs: StateFlow<Set<String>> = state.peerFavoritedUs
|
||||||
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
|
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
|
||||||
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
|
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
|
||||||
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
|
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
|
||||||
@ -243,7 +244,7 @@ class ChatViewModel(
|
|||||||
val myNick = state.getNicknameValue() ?: mesh.myPeerID
|
val myNick = state.getNicknameValue() ?: mesh.myPeerID
|
||||||
val unread = mutableSetOf<String>()
|
val unread = mutableSetOf<String>()
|
||||||
canonicalChats.forEach { (peer, list) ->
|
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)
|
state.setUnreadPrivateMessages(unread)
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
@ -324,6 +325,17 @@ class ChatViewModel(
|
|||||||
// Initialize favorites persistence service
|
// Initialize favorites persistence service
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
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
|
// Load verified fingerprints from secure storage
|
||||||
verificationHandler.loadVerifiedFingerprints()
|
verificationHandler.loadVerifiedFingerprints()
|
||||||
|
|
||||||
@ -630,8 +642,22 @@ class ChatViewModel(
|
|||||||
logCurrentFavoriteState()
|
logCurrentFavoriteState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logCurrentFavoriteState() {
|
private fun refreshPeerFavoritedUs() {
|
||||||
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
|
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", "StateFlow favorite peers: ${favoritePeers.value}")
|
||||||
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
|
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
|
||||||
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||||
|
|||||||
@ -44,24 +44,29 @@ class MeshDelegateHandler(
|
|||||||
onHapticFeedback()
|
onHapticFeedback()
|
||||||
|
|
||||||
if (message.isPrivate) {
|
if (message.isPrivate) {
|
||||||
// Private message
|
if (message.sender == "system") {
|
||||||
privateChatManager.handleIncomingPrivateMessage(message)
|
// 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
|
// Reactive read receipts: if chat is focused, send immediately for this message
|
||||||
message.senderPeerID?.let { senderPeerID ->
|
message.senderPeerID?.let { senderPeerID ->
|
||||||
sendReadReceiptIfFocused(message)
|
sendReadReceiptIfFocused(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show notification with enhanced information - now includes senderPeerID
|
// Show notification with enhanced information - now includes senderPeerID
|
||||||
message.senderPeerID?.let { senderPeerID ->
|
message.senderPeerID?.let { senderPeerID ->
|
||||||
// Use nickname if available, fall back to sender or senderPeerID
|
// Use nickname if available, fall back to sender or senderPeerID
|
||||||
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
|
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
|
||||||
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
|
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
|
||||||
notificationManager.showPrivateMessageNotification(
|
notificationManager.showPrivateMessageNotification(
|
||||||
senderPeerID = senderPeerID,
|
senderPeerID = senderPeerID,
|
||||||
senderNickname = senderNickname,
|
senderNickname = senderNickname,
|
||||||
messageContent = preview
|
messageContent = preview
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (message.channel != null) {
|
} else if (message.channel != null) {
|
||||||
// Channel message: AppStateStore is the source of truth for list; only manage unread
|
// 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 com.bitchat.android.R
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@ -27,6 +31,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.ContactDirectory
|
||||||
import com.bitchat.android.services.ContactIdentityResolver
|
import com.bitchat.android.services.ContactIdentityResolver
|
||||||
import com.bitchat.android.util.hexEncodedString
|
import com.bitchat.android.util.hexEncodedString
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -352,7 +358,7 @@ fun PeopleSection(
|
|||||||
// Observe reactive state for favorites and fingerprints
|
// Observe reactive state for favorites and fingerprints
|
||||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||||
val privateChats by viewModel.privateChats.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 peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -799,6 +805,7 @@ fun PrivateChatSheet(
|
|||||||
val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||||
|
val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle()
|
||||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||||
@ -815,7 +822,7 @@ fun PrivateChatSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||||
val favoriteRelationship = remember(peerID, favoritePeers) {
|
val favoriteRelationship = remember(peerID, favoritePeers, peerFavoritedUs) {
|
||||||
try {
|
try {
|
||||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@ -859,12 +866,55 @@ fun PrivateChatSheet(
|
|||||||
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
|
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
|
||||||
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
|
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) {
|
val isVerified = remember(peerID, verifiedFingerprints) {
|
||||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||||
}
|
}
|
||||||
|
|
||||||
val palette = LocalBitchatPalette.current
|
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(
|
val sheetState = rememberModalBottomSheetState(
|
||||||
skipPartiallyExpanded = true
|
skipPartiallyExpanded = true
|
||||||
)
|
)
|
||||||
@ -984,12 +1034,14 @@ fun PrivateChatSheet(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(HeaderIconSize),
|
modifier = Modifier
|
||||||
tint = if (isFavorite) {
|
.size(HeaderIconSize)
|
||||||
palette.accentOrange
|
.graphicsLayer {
|
||||||
} else {
|
rotationZ = starWobbleRotation.value
|
||||||
colorScheme.onSurfaceVariant
|
scaleX = starWobbleScale.value
|
||||||
}
|
scaleY = starWobbleScale.value
|
||||||
|
},
|
||||||
|
tint = favoriteStarTint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user