diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index f287784d..5d1f4b0b 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 6d3a9f3a..99244290 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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, - favoritePeers: Set -): 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, - isFavorite: Boolean, - sessionState: String?, - selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, - geohashPeople: List, - 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( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 9b26573b..0d22a0d5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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() + } + ) + } } 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 50a77f55..53a298ef 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -122,6 +122,9 @@ class ChatState( private val _showMeshPeerList = MutableStateFlow(false) val showMeshPeerList: StateFlow = _showMeshPeerList.asStateFlow() + private val _privateChatSheetPeer = MutableStateFlow(null) + val privateChatSheetPeer: StateFlow = _privateChatSheetPeer.asStateFlow() + private val _showVerificationSheet = MutableStateFlow(false) val showVerificationSheet: StateFlow = _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 + } } 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 07a5eea9..38d84096 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -171,6 +171,7 @@ class ChatViewModel( val peerDirect: StateFlow> = state.peerDirect val showAppInfo: StateFlow = state.showAppInfo val showMeshPeerList: StateFlow = state.showMeshPeerList + val privateChatSheetPeer: StateFlow = state.privateChatSheetPeer val showVerificationSheet: StateFlow = state.showVerificationSheet val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet val selectedLocationChannel: StateFlow = 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 } 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 eb20efcd..6b0a193c 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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(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,