Move PrivateChatSheet hosting into ChatDialogs and unify sheet state (#586)

* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Automated update of relay data - Sun Dec  7 06:22:59 UTC 2025

* Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025

* Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025

* Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025

* Automated update of relay data - Sun Jan  4 06:26:28 UTC 2026

* Automated update of relay data - Sun Jan 11 06:26:19 UTC 2026

* feat: Show private chat in sheet from notification

* Refactor: Hoist private chat sheet state to ChatViewModel

* remove icon

* remove old bottom sheet

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
yet300
2026-01-14 17:12:40 +07:00
committed by GitHub
co-authored by GitHub Action callebtc
parent 829fb52a04
commit 94b28b2107
6 changed files with 52 additions and 263 deletions
@@ -774,8 +774,9 @@ class MainActivity : OrientationAwareActivity() {
if (peerID != null) {
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
// Open the private chat with this peer
chatViewModel.startPrivateChat(peerID)
// Open the private chat sheet with this peer
chatViewModel.showMeshPeerList()
chatViewModel.showPrivateChatSheet(peerID)
// Clear notifications for this sender since user is now viewing the chat
chatViewModel.clearNotificationsForSender(peerID)
@@ -37,22 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
*/
/**
* Reactive helper to compute favorite state from fingerprint mapping
* This eliminates the need for static isFavorite parameters and makes
* the UI reactive to fingerprint manager changes
*/
@Composable
fun isFavoriteReactive(
peerID: String,
peerFingerprints: Map<String, String>,
favoritePeers: Set<String>
): Boolean {
return remember(peerID, peerFingerprints, favoritePeers) {
val fingerprint = peerFingerprints[peerID]
fingerprint != null && favoritePeers.contains(fingerprint)
}
}
@Composable
fun TorStatusDot(
@@ -246,39 +231,6 @@ fun ChatHeaderContent(
val colorScheme = MaterialTheme.colorScheme
when {
selectedPrivatePeer != null -> {
// Private chat header - Fully reactive state tracking
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
// Reactive favorite computation - no more static lookups!
val isFavorite = isFavoriteReactive(
peerID = selectedPrivatePeer,
peerFingerprints = peerFingerprints,
favoritePeers = favoritePeers
)
val sessionState = peerSessionStates[selectedPrivatePeer]
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
// Pass geohash context and people for NIP-17 chat title formatting
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
PrivateChatHeader(
peerID = selectedPrivatePeer,
peerNicknames = peerNicknames,
isFavorite = isFavorite,
sessionState = sessionState,
selectedLocationChannel = selectedLocationChannel,
geohashPeople = geohashPeople,
onBackClick = onBackClick,
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
viewModel = viewModel
)
}
currentChannel != null -> {
// Channel header
ChannelHeader(
@@ -304,168 +256,7 @@ fun ChatHeaderContent(
}
}
@Composable
private fun PrivateChatHeader(
peerID: String,
peerNicknames: Map<String, String>,
isFavorite: Boolean,
sessionState: String?,
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
geohashPeople: List<GeoPerson>,
onBackClick: () -> Unit,
onToggleFavorite: () -> Unit,
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val isVerified = remember(peerID, verifiedFingerprints) {
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
val isMutualFavorite = remember(peerID, peerNicknames) {
try {
if (isNostrDM) return@remember false
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
} else false
} catch (_: Exception) { false }
}
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
val titleText: String = if (isNostrDM) {
// For geohash DMs, get the actual source geohash and proper display name
val (conversationGeohash, baseName) = try {
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
repoField.isAccessible = true
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
val displayName = if (fullPubkey.isNotEmpty()) {
repo.displayNameForGeohashConversation(fullPubkey, gh)
} else {
peerNicknames[peerID] ?: "unknown"
}
Pair(gh, displayName)
} catch (e: Exception) {
Pair("geohash", peerNicknames[peerID] ?: "unknown")
}
"#$conversationGeohash/@$baseName"
} else {
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
peerNicknames[peerID] ?: run {
val titleFromFavorites = try {
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname
} else null
} catch (_: Exception) { null }
titleFromFavorites ?: peerID.take(12)
}
}
Box(modifier = Modifier.fillMaxWidth()) {
// Back button - positioned all the way to the left with minimal margin
Button(
onClick = onBackClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = colorScheme.primary
),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
modifier = Modifier
.align(Alignment.CenterStart)
.offset(x = (-8).dp) // Move even further left to minimize margin
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.chat_back),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
}
}
// Title - perfectly centered regardless of other elements
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = titleText,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF9500) // Orange
)
Spacer(modifier = Modifier.width(4.dp))
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
val securityModifier = if (!isNostrDM) {
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
} else {
Modifier
}
if (showGlobe) {
Icon(
imageVector = Icons.Outlined.Public,
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp).then(securityModifier),
tint = Color(0xFF9B59B6) // Purple like iOS
)
} else {
NoiseSessionIcon(
sessionState = sessionState,
modifier = Modifier.size(14.dp).then(securityModifier)
)
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(14.dp).then(securityModifier),
tint = Color(0xFF32D74B)
)
}
}
// Favorite button - positioned on the right
IconButton(
onClick = {
Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
onToggleFavorite()
},
modifier = Modifier.align(Alignment.CenterEnd)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey
)
}
}
}
@Composable
private fun ChannelHeader(
@@ -56,6 +56,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle()
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
@@ -86,8 +87,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Determine what messages to show based on current context (unified timelines)
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
val displayMessages = when {
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
else -> {
val locationChannel = selectedLocationChannel
@@ -102,7 +103,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Determine whether to show media buttons (only hide in geohash location chats)
val showMediaButtons = when {
selectedPrivatePeer != null -> true
currentChannel != null -> true
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
}
@@ -232,7 +232,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
selection = TextRange(mentionText.length)
)
},
selectedPrivatePeer = selectedPrivatePeer,
selectedPrivatePeer = null,
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme,
@@ -243,7 +243,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Floating header - positioned absolutely at top, ignores keyboard
ChatFloatingHeader(
headerHeight = headerHeight,
selectedPrivatePeer = selectedPrivatePeer,
selectedPrivatePeer = null,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
@@ -490,6 +490,8 @@ private fun ChatDialogs(
showMeshPeerListSheet: Boolean,
onMeshPeerListDismiss: () -> Unit,
) {
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
// Password dialog
PasswordPromptDialog(
show = showPasswordDialog,
@@ -570,4 +572,16 @@ private fun ChatDialogs(
viewModel = viewModel
)
}
if (privateChatSheetPeer != null) {
PrivateChatSheet(
isPresented = true,
peerID = privateChatSheetPeer!!,
viewModel = viewModel,
onDismiss = {
viewModel.hidePrivateChatSheet()
viewModel.endPrivateChat()
}
)
}
}
@@ -122,6 +122,9 @@ class ChatState(
private val _showMeshPeerList = MutableStateFlow(false)
val showMeshPeerList: StateFlow<Boolean> = _showMeshPeerList.asStateFlow()
private val _privateChatSheetPeer = MutableStateFlow<String?>(null)
val privateChatSheetPeer: StateFlow<String?> = _privateChatSheetPeer.asStateFlow()
private val _showVerificationSheet = MutableStateFlow(false)
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
@@ -188,6 +191,7 @@ class ChatState(
fun getGeohashPeopleValue() = _geohashPeople.value
fun getShowMeshPeerListValue() = _showMeshPeerList.value
fun getPrivateChatSheetPeerValue() = _privateChatSheetPeer.value
fun getTeleportedGeoValue() = _teleportedGeo.value
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
@@ -337,4 +341,8 @@ class ChatState(
fun setShowMeshPeerList(show: Boolean) {
_showMeshPeerList.value = show
}
fun setPrivateChatSheetPeer(peerID: String?) {
_privateChatSheetPeer.value = peerID
}
}
@@ -171,6 +171,7 @@ class ChatViewModel(
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val showMeshPeerList: StateFlow<Boolean> = state.showMeshPeerList
val privateChatSheetPeer: StateFlow<String?> = state.privateChatSheetPeer
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
@@ -403,6 +404,8 @@ class ChatViewModel(
setCurrentPrivateChatPeer(null)
// Clear mesh mention notifications since user is now back in mesh chat
clearMeshMentionNotifications()
// Ensure sheet is hidden
hidePrivateChatSheet()
}
// MARK: - Open Latest Unread Private Chat
@@ -451,7 +454,7 @@ class ChatViewModel(
canonical ?: targetKey
}
startPrivateChat(openPeer)
showPrivateChatSheet(openPeer)
} catch (e: Exception) {
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
}
@@ -796,6 +799,18 @@ class ChatViewModel(
fun hideMeshPeerList() {
state.setShowMeshPeerList(false)
if (state.getPrivateChatSheetPeerValue() != null) {
endPrivateChat()
}
hidePrivateChatSheet()
}
fun showPrivateChatSheet(peerID: String) {
state.setPrivateChatSheetPeer(peerID)
}
fun hidePrivateChatSheet() {
state.setPrivateChatSheetPeer(null)
}
fun getPeerFingerprintForDisplay(peerID: String): String? {
@@ -1006,7 +1021,7 @@ class ChatViewModel(
*/
fun startGeohashDM(pubkeyHex: String) {
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
startPrivateChat(convKey)
showPrivateChatSheet(convKey)
}
}
@@ -1049,7 +1064,7 @@ class ChatViewModel(
true
}
// Exit private chat
state.getSelectedPrivateChatPeerValue() != null -> {
state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> {
endPrivateChat()
true
}
@@ -57,10 +57,6 @@ fun MeshPeerListSheet(
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Track nested private chat sheet state
var showPrivateChatSheet by remember { mutableStateOf(false) }
var privateChatPeerID by remember { mutableStateOf<String?>(null) }
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
@@ -124,9 +120,8 @@ fun MeshPeerListSheet(
val peerName = channel.removePrefix("@")
val peerID =
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
if (peerID != null) {
privateChatPeerID = peerID
showPrivateChatSheet = true
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
}
} else {
// Regular channel switch
@@ -164,9 +159,7 @@ fun MeshPeerListSheet(
selectedPrivatePeer = selectedPrivatePeer,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.startPrivateChat(peerID)
privateChatPeerID = peerID
showPrivateChatSheet = true
viewModel.showPrivateChatSheet(peerID)
}
)
}
@@ -223,19 +216,6 @@ fun MeshPeerListSheet(
}
}
// Nested Private Chat Sheet (iOS-style)
if (showPrivateChatSheet && privateChatPeerID != null) {
PrivateChatSheet(
isPresented = showPrivateChatSheet,
peerID = privateChatPeerID!!,
viewModel = viewModel,
onDismiss = {
showPrivateChatSheet = false
privateChatPeerID = null
viewModel.endPrivateChat()
}
)
}
}
}
@@ -295,16 +275,6 @@ private fun ChannelRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Selection indicator
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.cd_selected),
tint = Color(0xFF32D74B), // iOS green
modifier = Modifier.size(20.dp)
)
}
// Leave channel button
CloseButton(
onClick = onLeaveChannel,
@@ -715,16 +685,6 @@ private fun PeerItem(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Selection indicator
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(R.string.cd_selected),
tint = Color(0xFF32D74B), // iOS green
modifier = Modifier.size(20.dp)
)
}
// Favorite star with proper filled/outlined states
IconButton(
onClick = onToggleFavorite,
@@ -802,7 +762,7 @@ private fun convertRSSIToSignalStrength(rssi: Int?): Int {
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PrivateChatSheet(
fun PrivateChatSheet(
isPresented: Boolean,
peerID: String,
viewModel: ChatViewModel,