diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 091cf630e..da8524cda 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Security @@ -551,24 +552,39 @@ fun AboutSheet( .height(64.dp) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) ) { - TextButton( + CloseButton( onClick = onDismiss, - modifier = Modifier + modifier = modifier .align(Alignment.CenterEnd) - .padding(horizontal = 16.dp) - ) { - Text( - text = stringResource(R.string.close_plain), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onBackground - ) - } + .padding(horizontal = 16.dp), + ) } } } } } +@Composable +fun CloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier + .size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f) + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + modifier = Modifier.size(18.dp) + ) + } +} /** * Password prompt dialog for password-protected channels * Kept as dialog since it requires user input 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 6ec8bef7e..f45050e0f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -5,7 +5,6 @@ package com.bitchat.android.ui import androidx.compose.animation.* -import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* @@ -51,7 +50,6 @@ fun ChatScreen(viewModel: ChatViewModel) { val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) val privateChats by viewModel.privateChats.observeAsState(emptyMap()) val channelMessages by viewModel.channelMessages.observeAsState(emptyMap()) - val showSidebar by viewModel.showSidebar.observeAsState(false) val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) @@ -64,6 +62,7 @@ fun ChatScreen(viewModel: ChatViewModel) { var passwordInput by remember { mutableStateOf("") } var showLocationChannelsSheet by remember { mutableStateOf(false) } var showLocationNotesSheet by remember { mutableStateOf(false) } + var showMeshPeerListSheet by remember { mutableStateOf(false) } var showUserSheet by remember { mutableStateOf(false) } var selectedUserForSheet by remember { mutableStateOf("") } var selectedMessageForSheet by remember { mutableStateOf(null) } @@ -247,7 +246,7 @@ fun ChatScreen(viewModel: ChatViewModel) { nickname = nickname, viewModel = viewModel, colorScheme = colorScheme, - onSidebarToggle = { viewModel.showSidebar() }, + onSidebarToggle = { showMeshPeerListSheet = true }, onShowAppInfo = { viewModel.showAppInfo() }, onPanicClear = { viewModel.panicClearAllData() }, onLocationChannelsClick = { showLocationChannelsSheet = true }, @@ -264,28 +263,9 @@ fun ChatScreen(viewModel: ChatViewModel) { color = colorScheme.outline.copy(alpha = 0.3f) ) - val alpha by animateFloatAsState( - targetValue = if (showSidebar) 0.5f else 0f, - animationSpec = tween( - durationMillis = 300, - easing = EaseOutCubic - ), label = "overlayAlpha" - ) - - // Only render the background if it's visible - if (alpha > 0f) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = alpha)) - .clickable { viewModel.hideSidebar() } - .zIndex(1f) - ) - } - // Scroll-to-bottom floating button AnimatedVisibility( - visible = isScrolledUp && !showSidebar, + visible = isScrolledUp, enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), modifier = Modifier @@ -311,25 +291,6 @@ fun ChatScreen(viewModel: ChatViewModel) { } } } - - AnimatedVisibility( - visible = showSidebar, - enter = slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(300, easing = EaseOutCubic) - ) + fadeIn(animationSpec = tween(300)), - exit = slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(250, easing = EaseInCubic) - ) + fadeOut(animationSpec = tween(250)), - modifier = Modifier.zIndex(2f) - ) { - SidebarOverlay( - viewModel = viewModel, - onDismiss = { viewModel.hideSidebar() }, - modifier = Modifier.fillMaxSize() - ) - } } // Full-screen image viewer - separate from other sheets to allow image browsing without navigation @@ -373,12 +334,16 @@ fun ChatScreen(viewModel: ChatViewModel) { }, selectedUserForSheet = selectedUserForSheet, selectedMessageForSheet = selectedMessageForSheet, + showMeshPeerListSheet = showMeshPeerListSheet, + onMeshPeerListDismiss = { + showMeshPeerListSheet = false + }, viewModel = viewModel ) } @Composable -private fun ChatInputSection( +fun ChatInputSection( messageText: TextFieldValue, onMessageTextChange: (TextFieldValue) -> Unit, onSend: () -> Unit, @@ -513,6 +478,8 @@ private fun ChatDialogs( onUserSheetDismiss: () -> Unit, selectedUserForSheet: String, selectedMessageForSheet: BitchatMessage?, + showMeshPeerListSheet: Boolean, + onMeshPeerListDismiss: () -> Unit, viewModel: ChatViewModel ) { // Password dialog @@ -567,4 +534,13 @@ private fun ChatDialogs( viewModel = viewModel ) } + + // MeshPeerList sheet (network view) + if (showMeshPeerListSheet){ + MeshPeerListSheet( + isPresented = showMeshPeerListSheet, + viewModel = viewModel, + onDismiss = onMeshPeerListDismiss + ) + } } 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 e17c14943..fc48c9c99 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -68,10 +68,6 @@ class ChatState { private val _passwordPromptChannel = MutableLiveData(null) val passwordPromptChannel: LiveData = _passwordPromptChannel - // Sidebar state - private val _showSidebar = MutableLiveData(false) - val showSidebar: LiveData = _showSidebar - // Command autocomplete private val _showCommandSuggestions = MutableLiveData(false) val showCommandSuggestions: LiveData = _showCommandSuggestions @@ -174,7 +170,6 @@ class ChatState { fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet() fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false fun getPasswordPromptChannelValue() = _passwordPromptChannel.value - fun getShowSidebarValue() = _showSidebar.value ?: false fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false @@ -248,10 +243,6 @@ class ChatState { _passwordPromptChannel.value = channel } - fun setShowSidebar(show: Boolean) { - _showSidebar.value = show - } - fun setShowCommandSuggestions(show: Boolean) { _showCommandSuggestions.value = show } 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 74ab15c67..8d3bbe861 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -118,7 +118,6 @@ class ChatViewModel( val passwordProtectedChannels: LiveData> = state.passwordProtectedChannels val showPasswordPrompt: LiveData = state.showPasswordPrompt val passwordPromptChannel: LiveData = state.passwordPromptChannel - val showSidebar: LiveData = state.showSidebar val hasUnreadChannels = state.hasUnreadChannels val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages val showCommandSuggestions: LiveData = state.showCommandSuggestions @@ -373,11 +372,6 @@ class ChatViewModel( } startPrivateChat(openPeer) - - // If sidebar visible, hide it to focus on the private chat - if (state.getShowSidebarValue()) { - state.setShowSidebar(false) - } } catch (e: Exception) { Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") } @@ -871,14 +865,6 @@ class ChatViewModel( state.setShowAppInfo(false) } - fun showSidebar() { - state.setShowSidebar(true) - } - - fun hideSidebar() { - state.setShowSidebar(false) - } - /** * Handle Android back navigation * Returns true if the back press was handled, false if it should be passed to the system @@ -890,11 +876,6 @@ class ChatViewModel( hideAppInfo() true } - // Close sidebar - state.getShowSidebarValue() -> { - hideSidebar() - true - } // Close password dialog state.getShowPasswordPromptValue() -> { state.setShowPasswordPrompt(false) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 251c8af90..0555e74ff 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -551,18 +551,12 @@ fun LocationChannelsSheet( .height(56.dp) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) ) { - TextButton( + CloseButton( onClick = onDismiss, - modifier = Modifier + modifier = modifier .align(Alignment.CenterEnd) - .padding(horizontal = 16.dp) - ) { - Text( - text = stringResource(R.string.close_plain), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onBackground - ) - } + .padding(horizontal = 16.dp), + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 23b9d952b..ed26c0e14 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -12,6 +13,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,7 +78,16 @@ fun LocationNotesSheet( // Scroll state val listState = rememberLazyListState() - + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + // Effect to set geohash when sheet opens LaunchedEffect(geohash) { notesManager.setGeohash(geohash) @@ -91,102 +102,118 @@ fun LocationNotesSheet( ModalBottomSheet( onDismissRequest = onDismiss, - modifier = modifier, + modifier = modifier.statusBarsPadding(), sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle = null, containerColor = backgroundColor, contentColor = if (isDark) Color.White else Color.Black ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.9f) - ) { - // Header section (matches iOS headerSection) - LocationNotesHeader( - geohash = geohash, - count = count, - locationName = displayLocationName, - state = state, - accentGreen = accentGreen, - backgroundColor = backgroundColor, - onClose = onDismiss - ) - - // ScrollView with notes content - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .background(backgroundColor) + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) - ) { - // Notes content (matches iOS notesContent) - when { - state == LocationNotesManager.State.NO_RELAYS -> { - item { - NoRelaysRow( - onRetry = { notesManager.refresh() } - ) - } + item(key = "notes_header") { + LocationNotesHeader( + geohash = geohash, + count = count, + locationName = displayLocationName, + state = state, + accentGreen = accentGreen, + backgroundColor = backgroundColor + ) + } + + // Notes content (matches iOS notesContent) + when { + state == LocationNotesManager.State.NO_RELAYS -> { + item { + NoRelaysRow( + onRetry = { notesManager.refresh() } + ) } - state == LocationNotesManager.State.LOADING && !initialLoadComplete -> { - item { - LoadingRow() - } + } + state == LocationNotesManager.State.LOADING && !initialLoadComplete -> { + item { + LoadingRow() } - notes.isEmpty() -> { - item { - EmptyRow() - } + } + notes.isEmpty() -> { + item { + EmptyRow() } - else -> { - items(notes, key = { it.id }) { note -> - NoteRow(note = note) - Spacer(modifier = Modifier.height(12.dp)) - } + } + else -> { + items(notes, key = { it.id }) { note -> + NoteRow(note = note) + Spacer(modifier = Modifier.height(24.dp)) + } + item { + Spacer(modifier = Modifier.height(24.dp)) } } - - // Error row (matches iOS errorRow) - errorMessage?.let { error -> - if (state != LocationNotesManager.State.NO_RELAYS) { - item { - ErrorRow( - message = error, - onDismiss = { notesManager.clearError() } - ) - } + } + + // Error row (matches iOS errorRow) + errorMessage?.let { error -> + if (state != LocationNotesManager.State.NO_RELAYS) { + item { + ErrorRow( + message = error, + onDismiss = { notesManager.clearError() } + ) } } } } - - // Divider before input (matches iOS overlay) - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), - thickness = 1.dp - ) - - // Input section (matches iOS inputSection) - LocationNotesInputSection( - draft = draft, - onDraftChange = { draft = it }, - sendButtonEnabled = sendButtonEnabled, - accentGreen = accentGreen, - backgroundColor = backgroundColor, - onSend = { - val content = draft.trim() - if (content.isNotEmpty()) { - notesManager.send(content, nickname) - draft = "" - } + + // TopBar (animated) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(64.dp) + .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) + ) { + CloseButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 16.dp) + ) + } + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ){ + Column { + // Divider before input (matches iOS overlay) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), + thickness = 1.dp + ) + + // Input section (matches iOS inputSection) + LocationNotesInputSection( + draft = draft, + onDraftChange = { draft = it }, + sendButtonEnabled = sendButtonEnabled, + accentGreen = accentGreen, + backgroundColor = backgroundColor, + onSend = { + val content = draft.trim() + if (content.isNotEmpty()) { + notesManager.send(content, nickname) + draft = "" + } + } + ) } - ) + } } } } @@ -203,51 +230,25 @@ private fun LocationNotesHeader( state: LocationNotesManager.State, accentGreen: Color, backgroundColor: Color, - onClose: () -> Unit ) { Column( modifier = Modifier .fillMaxWidth() .background(backgroundColor) - .padding(horizontal = 16.dp) .padding(top = 16.dp, bottom = 12.dp) ) { - // Title row with close button - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Localized title with ±1 and note count - Text( - text = pluralStringResource( - id = R.plurals.location_notes_title, - count = count, - geohash, - count - ), - fontFamily = FontFamily.Monospace, - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface - ) - - // Close button - iOS style with xmark icon - Box( - modifier = Modifier - .size(32.dp) - .clickable(onClick = onClose), - contentAlignment = Alignment.Center - ) { - Text( - text = "✕", - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - + // Localized title with ±1 and note count + Text( + text = pluralStringResource( + id = R.plurals.location_notes_title, + count = count, + geohash, + count + ), + fontFamily = FontFamily.Monospace, + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface + ) Spacer(modifier = Modifier.height(8.dp)) // Location name in green (building or block) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt new file mode 100644 index 000000000..a578cd0f4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -0,0 +1,982 @@ +package com.bitchat.android.ui + +import com.bitchat.android.R +import android.util.Log +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.style.TextOverflow +import com.bitchat.android.ui.theme.BASE_FONT_SIZE + + +/** + * Sheet components for ChatScreen + * Extracted from ChatScreen.kt for better organization + */ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MeshPeerListSheet( + isPresented: Boolean, + viewModel: ChatViewModel, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) + val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList()) + val currentChannel by viewModel.currentChannel.observeAsState() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() + val nickname by viewModel.nickname.observeAsState("") + val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) + val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) + val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap()) + + // 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 + ) + + // Scroll state for animated top bar + val listState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + + if (isPresented) { + ModalBottomSheet( + modifier = modifier.statusBarsPadding(), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background, + dragHandle = null + ) { + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + ) { + // Channels section + if (joinedChannels.isNotEmpty()) { + item(key = "channels_header") { + Text( + text = stringResource(id = R.string.channels).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 4.dp) + ) + } + + items( + items = joinedChannels.toList(), + key = { "channel_$it" } + ) { channel -> + val isSelected = channel == currentChannel + val unreadCount = unreadChannelMessages[channel] ?: 0 + + ChannelRow( + channel = channel, + isSelected = isSelected, + unreadCount = unreadCount, + colorScheme = colorScheme, + onChannelClick = { + // Check if this is a DM channel (starts with @) + if (channel.startsWith("@")) { + // Extract peer name and find the peer ID + val peerName = channel.removePrefix("@") + val peerID = + peerNicknames.entries.firstOrNull { it.value == peerName }?.key + if (peerID != null) { + privateChatPeerID = peerID + showPrivateChatSheet = true + } + } else { + // Regular channel switch + viewModel.switchToChannel(channel) + onDismiss() + } + }, + onLeaveChannel = { + viewModel.leaveChannel(channel) + } + ) + } + } + + // People section - switch between mesh and geohash lists (iOS-compatible) + item(key = "people_section") { + val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + + when (selectedLocationChannel) { + is com.bitchat.android.geohash.ChannelID.Location -> { + // Show geohash people list when in location channel + GeohashPeopleList( + viewModel = viewModel, + onTapPerson = onDismiss + ) + } + + else -> { + // Show mesh peer list when in mesh channel (default) + PeopleSection( + modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp), + connectedPeers = connectedPeers, + peerNicknames = peerNicknames, + peerRSSI = peerRSSI, + nickname = nickname, + colorScheme = colorScheme, + selectedPrivatePeer = selectedPrivatePeer, + viewModel = viewModel, + onPrivateChatStart = { peerID -> + viewModel.startPrivateChat(peerID) + privateChatPeerID = peerID + showPrivateChatSheet = true + } + ) + } + } + } + } + + // TopBar (animated) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(64.dp) + .background(colorScheme.background.copy(alpha = topBarAlpha)) + ) { + Text( + text = stringResource(id = R.string.your_network).uppercase(), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ), + color = colorScheme.onSurface, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(horizontal = 24.dp) + ) + + CloseButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 16.dp) + ) + } + } + } + + // Nested Private Chat Sheet (iOS-style) + if (showPrivateChatSheet && privateChatPeerID != null) { + PrivateChatSheet( + isPresented = showPrivateChatSheet, + peerID = privateChatPeerID!!, + viewModel = viewModel, + onDismiss = { + showPrivateChatSheet = false + privateChatPeerID = null + viewModel.endPrivateChat() + } + ) + } + } +} + +@Composable +private fun ChannelRow( + channel: String, + isSelected: Boolean, + unreadCount: Int, + colorScheme: ColorScheme, + onChannelClick: () -> Unit, + onLeaveChannel: () -> Unit +) { + Surface( + onClick = onChannelClick, + color = if (isSelected) { + colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Unread badge + if (unreadCount > 0) { + UnreadBadge( + count = unreadCount, + colorScheme = colorScheme + ) + } + + Text( + text = channel, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp + ), + color = if (isSelected) colorScheme.primary else colorScheme.onSurface, + fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal + ) + } + + Row( + 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 + IconButton( + onClick = onLeaveChannel, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_leave_channel), + modifier = Modifier.size(16.dp), + tint = colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } + } + } +} + + + +@Composable +fun PeopleSection( + modifier: Modifier = Modifier, + connectedPeers: List, + peerNicknames: Map, + peerRSSI: Map, + nickname: String, + colorScheme: ColorScheme, + selectedPrivatePeer: String?, + viewModel: ChatViewModel, + onPrivateChatStart: (String) -> Unit +) { + Column(modifier = modifier) { + Text( + text = stringResource(id = R.string.people).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 8.dp, bottom = 4.dp) + ) + + if (connectedPeers.isEmpty()) { + Text( + text = stringResource(id = R.string.no_one_connected), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ), + color = colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 12.dp) + ) + } + + // Observe reactive state for favorites and fingerprints + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) + val privateChats by viewModel.privateChats.observeAsState(emptyMap()) + val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) + val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) + + // Reactive favorite computation for all peers + val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { + connectedPeers.associateWith { peerID -> + // Reactive favorite computation - same as ChatHeader + val fingerprint = peerFingerprints[peerID] + fingerprint != null && favoritePeers.contains(fingerprint) + } + } + + // Build mapping of connected peerID -> noise key hex to unify with offline favorites + val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> + try { + viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } + } catch (_: Exception) { null } + }.filterValues { it != null }.mapValues { it.value!! } + + Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") + + // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical + val sortedPeers = connectedPeers.sortedWith( + compareBy { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first + .thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long) + .thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first + .thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical + ) + + // Build a map of base name counts across all people shown in the list (connected + offline + nostr) + val hex64Regex = Regex("^[0-9a-fA-F]{64}$") + + // Helper to compute display name used for a given key + fun computeDisplayNameForPeerId(key: String): String { + return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12))) + } + + val baseNameCounts = mutableMapOf() + + // Connected peers + sortedPeers.forEach { pid -> + val dn = computeDisplayNameForPeerId(pid) + val (b, _) = com.bitchat.android.ui.splitSuffix(dn) + if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 + } + + // Offline favorites (exclude ones mapped to connected) + val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() + offlineFavorites.forEach { fav -> + val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } + if (!isMappedToConnected) { + val dn = peerNicknames[favPeerID] ?: fav.peerNickname + val (b, _) = com.bitchat.android.ui.splitSuffix(dn) + if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 + } + } + + // Nostr-only conversations + val connectedIds = sortedPeers.toSet() + val appendedOfflineIds = mutableSetOf() + privateChats.keys + .filter { key -> + (key.startsWith("nostr_") || hex64Regex.matches(key)) && + !connectedIds.contains(key) && + !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } + } + .forEach { convKey -> + val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12)) + val (b, _) = com.bitchat.android.ui.splitSuffix(dn) + if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 + } + + sortedPeers.forEach { peerID -> + val isFavorite = peerFavoriteStates[peerID] ?: false + // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below + + val noiseHex = noiseHexByPeerID[peerID] + val meshUnread = hasUnreadPrivateMessages.contains(peerID) + val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false + val combinedHasUnread = meshUnread || nostrUnread + val combinedUnreadCount = ( + privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0 + ) + ( + if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0 + ) + + val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12))) + val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + val directMap by viewModel.peerDirect.observeAsState(emptyMap()) + val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } + PeerItem( + peerID = peerID, + displayName = displayName, + isDirect = isDirectLive, + isSelected = peerID == selectedPrivatePeer, + isFavorite = isFavorite, + hasUnreadDM = combinedHasUnread, + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(peerID) }, + onToggleFavorite = { + Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") + viewModel.toggleFavorite(peerID) + }, + unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0, + showNostrGlobe = false, + showHashSuffix = showHash + ) + } + + // Append offline favorites we actively favorite (and not currently connected) + offlineFavorites.forEach { fav -> + val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } + // If any connected peer maps to this noise key, skip showing the offline entry + val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } + if (isMappedToConnected) return@forEach + + // Resolve potential Nostr conversation key for this favorite (for unread detection) + val nostrConvKey: String? = try { + val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) + if (npubOrHex != null) { + val hex = if (npubOrHex.startsWith("npub")) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) + if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null + } else { + npubOrHex.lowercase() + } + hex?.let { "nostr_${it.take(16)}" } + } else null + } catch (_: Exception) { null } + + val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey)) + + // If user clicks an offline favorite and the mapped peer is currently connected under a different ID, + // open chat with the connected peerID instead of the noise hex for a seamless window + val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key + val dn = peerNicknames[favPeerID] ?: fav.peerNickname + val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + // Compute unreadCount from either noise conversation or Nostr conversation + val unreadCount = ( + privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0 + ) + ( + if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0 + ) + + PeerItem( + peerID = favPeerID, + displayName = dn, + isDirect = false, + isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, + isFavorite = true, + hasUnreadDM = hasUnread, + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, + onToggleFavorite = { + Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") + viewModel.toggleFavorite(favPeerID) + }, + unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, + showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), + showHashSuffix = showHash + ) + appendedOfflineIds.add(favPeerID) + } + + // NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list. + // Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts. + // We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar. + // If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only. + /* + val alreadyShownIds = connectedIds + appendedOfflineIds + privateChats.keys + .filter { key -> + // Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases + hex64Regex.matches(key) && + !alreadyShownIds.contains(key) && + // Skip if this key maps to a connected peer via noiseHex mapping + !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } + } + .sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp } + .forEach { convKey -> + val lastSender = privateChats[convKey]?.lastOrNull()?.sender + val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12)) + val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) + val showHash = (baseNameCounts[bName] ?: 0) > 1 + + PeerItem( + peerID = convKey, + displayName = dn, + isDirect = false, + isSelected = convKey == selectedPrivatePeer, + isFavorite = false, + hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), + colorScheme = colorScheme, + viewModel = viewModel, + onItemClick = { onPrivateChatStart(convKey) }, + onToggleFavorite = { viewModel.toggleFavorite(convKey) }, + unreadCount = privateChats[convKey]?.count { msg -> + msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey) + } ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0, + showNostrGlobe = false, + showHashSuffix = showHash + ) + } + */ + // End intentional removal + + } +} + +@Composable +private fun PeerItem( + peerID: String, + displayName: String, + isDirect: Boolean, + isSelected: Boolean, + isFavorite: Boolean, + hasUnreadDM: Boolean, + colorScheme: ColorScheme, + viewModel: ChatViewModel, + onItemClick: () -> Unit, + onToggleFavorite: () -> Unit, + unreadCount: Int = 0, + showNostrGlobe: Boolean = false, + showHashSuffix: Boolean = true +) { + // Split display name for hashtag suffix support (iOS-compatible) + val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(displayName) + val baseName = truncateNickname(baseNameRaw) + val suffix = if (showHashSuffix) suffixRaw else "" + val isMe = displayName == "You" || peerID == viewModel.nickname.value + + // Get consistent peer color (iOS-compatible) + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) + val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor + + Surface( + onClick = onItemClick, + color = if (isSelected) { + colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + shape = MaterialTheme.shapes.medium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Connection/status indicator + if (hasUnreadDM) { + // Show mail icon for unread DMs (iOS orange) + Icon( + imageVector = Icons.Filled.Email, + contentDescription = stringResource(com.bitchat.android.R.string.cd_unread_message), + modifier = Modifier.size(16.dp), + tint = Color(0xFFFF9500) // iOS orange + ) + } else if (showNostrGlobe) { + // Purple globe to indicate Nostr availability + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(com.bitchat.android.R.string.cd_reachable_via_nostr), + modifier = Modifier.size(16.dp), + tint = Color(0xFF9C27B0) // Purple + ) + } else if (!isDirect && isFavorite) { + // Offline favorited user: show outlined circle icon + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = stringResource(com.bitchat.android.R.string.cd_offline_favorite), + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) + } else { + Icon( + imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route, + contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", + modifier = Modifier.size(16.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + + // Display name with iOS-style color and hashtag suffix support + Row(verticalAlignment = Alignment.CenterVertically) { + // Base name with peer-specific color + Text( + text = baseName, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp, + fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal + ), + color = baseColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Hashtag suffix in lighter shade (iOS-style) + if (suffix.isNotEmpty()) { + Text( + text = suffix, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = BASE_FONT_SIZE.sp + ), + color = baseColor.copy(alpha = 0.6f) + ) + } + } + } + + Row( + 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, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + modifier = Modifier.size(16.dp), + tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) + ) + } + } + } + } +} + +/** + * Reusable unread badge component for both channels and private messages + */ +@Composable +private fun UnreadBadge( + count: Int, + colorScheme: ColorScheme, + modifier: Modifier = Modifier +) { + if (count > 0) { + Box( + modifier = modifier + .background( + color = Color(0xFFFFD700), // Yellow color + shape = RoundedCornerShape(10.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (count > 99) "99+" else count.toString(), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ), + color = Color.Black // Black text on yellow background + ) + } + } +} + +/** + * Convert RSSI value (dBm) to signal strength percentage (0-100) + * RSSI typically ranges from -30 (excellent) to -100 (very poor) + * Maps to 0-100 scale where: + * - 0-32: No signal (0 bars) + * - 33-65: Weak (1 bar) + * - 66-98: Good (2 bars) + * - 99-100: Excellent (3 bars) + */ +private fun convertRSSIToSignalStrength(rssi: Int?): Int { + if (rssi == null) return 0 + + return when { + rssi >= -40 -> 100 // Excellent signal + rssi >= -55 -> 85 // Very good signal + rssi >= -70 -> 70 // Good signal + rssi >= -85 -> 50 // Fair signal + rssi >= -100 -> 25 // Poor signal + else -> 0 // Very poor or no signal + } +} + +/** + * Nested Private Chat Sheet - iOS-style nested bottom sheet + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PrivateChatSheet( + isPresented: Boolean, + peerID: String, + viewModel: ChatViewModel, + onDismiss: () -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + val privateChats by viewModel.privateChats.observeAsState(emptyMap()) + val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) + val nickname by viewModel.nickname.observeAsState("") + val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) + val peerDirectMap by viewModel.peerDirect.observeAsState(emptyMap()) + val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) + val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) + val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) + + // Start private chat when screen opens + LaunchedEffect(peerID) { + viewModel.startPrivateChat(peerID) + } + + val displayName = peerNicknames[peerID] ?: peerID.take(12) + val messages = privateChats[peerID] ?: emptyList() + val isDirect = peerDirectMap[peerID] == true + val isConnected = connectedPeers.contains(peerID) || isDirect + val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") + val sessionState = peerSessionStates[peerID] + val fingerprint = peerFingerprints[peerID] + val isFavorite = remember(favoritePeers, fingerprint) { + if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID) + } + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + if (isPresented) { + ModalBottomSheet( + modifier = Modifier.statusBarsPadding(), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = colorScheme.background, + dragHandle = null + ) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Spacer(modifier = Modifier.height(64.dp)) + + HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + + // Messages list + var forceScrollToBottom by remember { mutableStateOf(false) } + var isScrolledUp by remember { mutableStateOf(false) } + + MessagesList( + messages = messages, + currentUserNickname = nickname, + meshService = viewModel.meshService, + modifier = Modifier.weight(1f), + forceScrollToBottom = forceScrollToBottom, + onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + onNicknameClick = { /* handle mention */ }, + onMessageLongPress = { /* handle long press */ }, + onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) }, + onImageClick = { _, _, _ -> /* handle image click */ } + ) + + HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) + + // Input section + var messageText by remember { + mutableStateOf( + androidx.compose.ui.text.input.TextFieldValue( + "" + ) + ) + } + + ChatInputSection( + messageText = messageText, + onMessageTextChange = { newText -> + messageText = newText + viewModel.updateMentionSuggestions(newText.text) + }, + onSend = { + if (messageText.text.trim().isNotEmpty()) { + viewModel.sendMessage(messageText.text.trim()) + messageText = androidx.compose.ui.text.input.TextFieldValue("") + forceScrollToBottom = !forceScrollToBottom + } + }, + onSendVoiceNote = { peer, channel, path -> + viewModel.sendVoiceNote(peer, channel, path) + }, + onSendImageNote = { peer, channel, path -> + viewModel.sendImageNote(peer, channel, path) + }, + onSendFileNote = { peer, channel, path -> + viewModel.sendFileNote(peer, channel, path) + }, + showCommandSuggestions = false, + commandSuggestions = emptyList(), + showMentionSuggestions = false, + mentionSuggestions = emptyList(), + onCommandSuggestionClick = { }, + onMentionSuggestionClick = { }, + selectedPrivatePeer = peerID, + currentChannel = null, + nickname = nickname, + colorScheme = colorScheme, + showMediaButtons = true + ) + } + + // TopBar (fixed at top, iOS-style) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(64.dp) + .background(colorScheme.background), + contentAlignment = Alignment.Center + ) { + // Back button + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp) + .size(32.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.chat_back), + tint = colorScheme.onSurface + ) + } + + // Center content: connection status + name + encryption + Row( + modifier = Modifier.align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + when { + isDirect -> { + Icon( + imageVector = Icons.Outlined.SettingsInputAntenna, + contentDescription = stringResource(R.string.cd_connected_peers), + modifier = Modifier.size(14.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + isConnected -> { + Icon( + imageVector = Icons.Filled.Route, + contentDescription = stringResource(R.string.cd_ready_for_handshake), + modifier = Modifier.size(14.dp), + tint = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + isNostrPeer -> { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = stringResource(R.string.cd_nostr_reachable), + modifier = Modifier.size(14.dp), + tint = Color(0xFF9C27B0) + ) + } + } + + Text( + text = displayName, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace + ), + color = colorScheme.onSurface + ) + + if (!isNostrPeer) { + NoiseSessionIcon( + sessionState = sessionState, + modifier = Modifier.size(14.dp) + ) + } + + IconButton( + onClick = { viewModel.toggleFavorite(peerID) }, + modifier = Modifier.size(28.dp) + ) { + 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(16.dp), + tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + CloseButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 16.dp) + ) + + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt deleted file mode 100644 index a8c59ef7e..000000000 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ /dev/null @@ -1,714 +0,0 @@ -package com.bitchat.android.ui - -import com.bitchat.android.R -import android.util.Log -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material.icons.outlined.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.style.TextOverflow -import com.bitchat.android.ui.theme.BASE_FONT_SIZE - - -/** - * Sidebar components for ChatScreen - * Extracted from ChatScreen.kt for better organization - */ - -@Composable -fun SidebarOverlay( - viewModel: ChatViewModel, - onDismiss: () -> Unit, - modifier: Modifier = Modifier -) { - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList()) - val currentChannel by viewModel.currentChannel.observeAsState() - val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() - val nickname by viewModel.nickname.observeAsState("") - val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) - val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap()) - - Box( - modifier = modifier - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(indication = null, interactionSource = interactionSource) { onDismiss() } - ) { - Row( - modifier = Modifier - .fillMaxHeight() - .width(280.dp) - .align(Alignment.CenterEnd) - .clickable { /* Prevent dismissing when clicking sidebar */ } - ) { - // Grey vertical bar for visual continuity (matches iOS) - Box( - modifier = Modifier - .fillMaxHeight() - .width(1.dp) - .background(Color.Gray.copy(alpha = 0.3f)) - ) - - Column( - modifier = Modifier - .fillMaxHeight() - .weight(1f) - .background(colorScheme.background.copy(alpha = 0.95f)) - .windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding - ) { - SidebarHeader() - - HorizontalDivider() - - // Scrollable content - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Channels section - if (joinedChannels.isNotEmpty()) { - item { - ChannelsSection( - channels = joinedChannels.toList(), // Convert Set to List - currentChannel = currentChannel, - colorScheme = colorScheme, - onChannelClick = { channel -> - viewModel.switchToChannel(channel) - onDismiss() - }, - onLeaveChannel = { channel -> - viewModel.leaveChannel(channel) - }, - unreadChannelMessages = unreadChannelMessages - ) - } - - item { - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - } - } - - // People section - switch between mesh and geohash lists (iOS-compatible) - item { - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - - when (selectedLocationChannel) { - is com.bitchat.android.geohash.ChannelID.Location -> { - // Show geohash people list when in location channel - GeohashPeopleList( - viewModel = viewModel, - onTapPerson = onDismiss - ) - } - else -> { - // Show mesh peer list when in mesh channel (default) - PeopleSection( - modifier = modifier.padding(bottom = 16.dp), - connectedPeers = connectedPeers, - peerNicknames = peerNicknames, - peerRSSI = peerRSSI, - nickname = nickname, - colorScheme = colorScheme, - selectedPrivatePeer = selectedPrivatePeer, - viewModel = viewModel, - onPrivateChatStart = { peerID -> - viewModel.startPrivateChat(peerID) - onDismiss() - } - ) - } - } - } - } - } - } - } -} - -@Composable -private fun SidebarHeader() { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .height(42.dp) // Match reduced main header height - .fillMaxWidth() - .background(colorScheme.background.copy(alpha = 0.95f)) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.your_network).uppercase(), - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ), - color = colorScheme.onSurface - ) - Spacer(modifier = Modifier.weight(1f)) - } -} - -@Composable -fun ChannelsSection( - channels: List, - currentChannel: String?, - colorScheme: ColorScheme, - onChannelClick: (String) -> Unit, - onLeaveChannel: (String) -> Unit, - unreadChannelMessages: Map = emptyMap() -) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Person, // Using Person icon as placeholder - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(id = R.string.channels).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold - ) - } - - channels.forEach { channel -> - val isSelected = channel == currentChannel - val unreadCount = unreadChannelMessages[channel] ?: 0 - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onChannelClick(channel) } - .background( - if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f) - else Color.Transparent - ) - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Unread badge for channels - UnreadBadge( - count = unreadCount, - colorScheme = colorScheme, - modifier = Modifier.padding(end = 8.dp) - ) - - Text( - text = channel, // Channel already contains the # prefix - style = MaterialTheme.typography.bodyMedium, - color = if (isSelected) colorScheme.primary else colorScheme.onSurface, - fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, - modifier = Modifier.weight(1f) - ) - - // Leave channel button - IconButton( - onClick = { onLeaveChannel(channel) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(com.bitchat.android.R.string.cd_leave_channel), - modifier = Modifier.size(14.dp), - tint = colorScheme.onSurface.copy(alpha = 0.5f) - ) - } - } - } - } -} - -@Composable -fun PeopleSection( - modifier: Modifier = Modifier, - connectedPeers: List, - peerNicknames: Map, - peerRSSI: Map, - nickname: String, - colorScheme: ColorScheme, - selectedPrivatePeer: String?, - viewModel: ChatViewModel, - onPrivateChatStart: (String) -> Unit -) { - Column(modifier = modifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Group, // Using Person icon for people - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = colorScheme.onSurface.copy(alpha = 0.6f) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(id = R.string.people).uppercase(), - style = MaterialTheme.typography.labelSmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold - ) - } - - if (connectedPeers.isEmpty()) { - Text( - text = stringResource(id = R.string.no_one_connected), - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) - ) - } - - // Observe reactive state for favorites and fingerprints - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val privateChats by viewModel.privateChats.observeAsState(emptyMap()) - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) - - // Reactive favorite computation for all peers - val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { - connectedPeers.associateWith { peerID -> - // Reactive favorite computation - same as ChatHeader - val fingerprint = peerFingerprints[peerID] - fingerprint != null && favoritePeers.contains(fingerprint) - } - } - - // Build mapping of connected peerID -> noise key hex to unify with offline favorites - val noiseHexByPeerID: Map = connectedPeers.associateWith { pid -> - try { - viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.joinToString("") { b -> "%02x".format(b) } - } catch (_: Exception) { null } - }.filterValues { it != null }.mapValues { it.value!! } - - Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates") - - // Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical - val sortedPeers = connectedPeers.sortedWith( - compareBy { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first - .thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long) - .thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first - .thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical - ) - - // Build a map of base name counts across all people shown in the list (connected + offline + nostr) - val hex64Regex = Regex("^[0-9a-fA-F]{64}$") - - // Helper to compute display name used for a given key - fun computeDisplayNameForPeerId(key: String): String { - return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12))) - } - - val baseNameCounts = mutableMapOf() - - // Connected peers - sortedPeers.forEach { pid -> - val dn = computeDisplayNameForPeerId(pid) - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - - // Offline favorites (exclude ones mapped to connected) - val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() - offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } - if (!isMappedToConnected) { - val dn = peerNicknames[favPeerID] ?: fav.peerNickname - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - } - - // Nostr-only conversations - val connectedIds = sortedPeers.toSet() - val appendedOfflineIds = mutableSetOf() - privateChats.keys - .filter { key -> - (key.startsWith("nostr_") || hex64Regex.matches(key)) && - !connectedIds.contains(key) && - !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } - } - .forEach { convKey -> - val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12)) - val (b, _) = com.bitchat.android.ui.splitSuffix(dn) - if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1 - } - - sortedPeers.forEach { peerID -> - val isFavorite = peerFavoriteStates[peerID] ?: false - // fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below - - val noiseHex = noiseHexByPeerID[peerID] - val meshUnread = hasUnreadPrivateMessages.contains(peerID) - val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false - val combinedHasUnread = meshUnread || nostrUnread - val combinedUnreadCount = ( - privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0 - ) + ( - if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0 - ) - - val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12))) - val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - val directMap by viewModel.peerDirect.observeAsState(emptyMap()) - val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } - PeerItem( - peerID = peerID, - displayName = displayName, - isDirect = isDirectLive, - isSelected = peerID == selectedPrivatePeer, - isFavorite = isFavorite, - hasUnreadDM = combinedHasUnread, - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(peerID) }, - onToggleFavorite = { - Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite") - viewModel.toggleFavorite(peerID) - }, - unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0, - showNostrGlobe = false, - showHashSuffix = showHash - ) - } - - // Append offline favorites we actively favorite (and not currently connected) - offlineFavorites.forEach { fav -> - val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } - // If any connected peer maps to this noise key, skip showing the offline entry - val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } - if (isMappedToConnected) return@forEach - - // Resolve potential Nostr conversation key for this favorite (for unread detection) - val nostrConvKey: String? = try { - val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) - if (npubOrHex != null) { - val hex = if (npubOrHex.startsWith("npub")) { - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) - if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null - } else { - npubOrHex.lowercase() - } - hex?.let { "nostr_${it.take(16)}" } - } else null - } catch (_: Exception) { null } - - val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey)) - - // If user clicks an offline favorite and the mapped peer is currently connected under a different ID, - // open chat with the connected peerID instead of the noise hex for a seamless window - val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key - val dn = peerNicknames[favPeerID] ?: fav.peerNickname - val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - // Compute unreadCount from either noise conversation or Nostr conversation - val unreadCount = ( - privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0 - ) + ( - if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0 - ) - - PeerItem( - peerID = favPeerID, - displayName = dn, - isDirect = false, - isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, - isFavorite = true, - hasUnreadDM = hasUnread, - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, - onToggleFavorite = { - Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") - viewModel.toggleFavorite(favPeerID) - }, - unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, - showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), - showHashSuffix = showHash - ) - appendedOfflineIds.add(favPeerID) - } - - // NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list. - // Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts. - // We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar. - // If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only. - /* - val alreadyShownIds = connectedIds + appendedOfflineIds - privateChats.keys - .filter { key -> - // Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases - hex64Regex.matches(key) && - !alreadyShownIds.contains(key) && - // Skip if this key maps to a connected peer via noiseHex mapping - !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } - } - .sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp } - .forEach { convKey -> - val lastSender = privateChats[convKey]?.lastOrNull()?.sender - val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12)) - val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) - val showHash = (baseNameCounts[bName] ?: 0) > 1 - - PeerItem( - peerID = convKey, - displayName = dn, - isDirect = false, - isSelected = convKey == selectedPrivatePeer, - isFavorite = false, - hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), - colorScheme = colorScheme, - viewModel = viewModel, - onItemClick = { onPrivateChatStart(convKey) }, - onToggleFavorite = { viewModel.toggleFavorite(convKey) }, - unreadCount = privateChats[convKey]?.count { msg -> - msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey) - } ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0, - showNostrGlobe = false, - showHashSuffix = showHash - ) - } - */ - // End intentional removal - - } -} - -@Composable -private fun PeerItem( - peerID: String, - displayName: String, - isDirect: Boolean, - isSelected: Boolean, - isFavorite: Boolean, - hasUnreadDM: Boolean, - colorScheme: ColorScheme, - viewModel: ChatViewModel, - onItemClick: () -> Unit, - onToggleFavorite: () -> Unit, - unreadCount: Int = 0, - showNostrGlobe: Boolean = false, - showHashSuffix: Boolean = true -) { - // Split display name for hashtag suffix support (iOS-compatible) - val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(displayName) - val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - val isMe = displayName == "You" || peerID == viewModel.nickname.value - - // Get consistent peer color (iOS-compatible) - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val assignedColor = viewModel.colorForMeshPeer(peerID, isDark) - val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onItemClick() } - .background( - if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f) - else Color.Transparent - ) - .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Show unread badge or signal strength - if (hasUnreadDM) { - // Show mail icon for unread DMs (iOS orange) - Icon( - imageVector = Icons.Filled.Email, - contentDescription = stringResource(com.bitchat.android.R.string.cd_unread_message), - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // iOS orange - ) - } else { - // Connection indicator icons - if (showNostrGlobe) { - // Purple globe to indicate Nostr availability - Icon( - imageVector = Icons.Filled.Public, - contentDescription = stringResource(com.bitchat.android.R.string.cd_reachable_via_nostr), - modifier = Modifier.size(16.dp), - tint = Color(0xFF9C27B0) // Purple - ) - } else if (!isDirect && isFavorite) { - // Offline favorited user: show outlined circle icon - Icon( - //painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite), - imageVector = Icons.Outlined.Circle, - contentDescription = stringResource(com.bitchat.android.R.string.cd_offline_favorite), - modifier = Modifier.size(16.dp), - tint = Color.Gray - ) - } else { - Icon( - imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route, - contentDescription = if (isDirect) "Direct Bluetooth" else "Routed", - modifier = Modifier.size(16.dp), - tint = colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - } - - Spacer(modifier = Modifier.width(8.dp)) - - // Display name with iOS-style color and hashtag suffix support - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - // Base name with peer-specific color - Text( - text = baseName, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal - ), - color = baseColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - // Hashtag suffix in lighter shade (iOS-style) - if (suffix.isNotEmpty()) { - Text( - text = suffix, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = BASE_FONT_SIZE.sp - ), - color = baseColor.copy(alpha = 0.6f) - ) - } - } - - // Favorite star with proper filled/outlined states - IconButton( - onClick = onToggleFavorite, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", - modifier = Modifier.size(16.dp), - tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50) - ) - } - } -} - - - -@Composable -private fun SignalStrengthIndicator( - signalStrength: Int, - colorScheme: ColorScheme -) { - Row(modifier = Modifier.width(24.dp)) { - repeat(3) { index -> - val opacity = when { - signalStrength >= (index + 1) * 33 -> 1f - else -> 0.2f - } - Box( - modifier = Modifier - .size(width = 3.dp, height = (4 + index * 2).dp) - .background( - colorScheme.onSurface.copy(alpha = opacity), - RoundedCornerShape(1.dp) - ) - ) - if (index < 2) Spacer(modifier = Modifier.width(2.dp)) - } - } -} - -/** - * Reusable unread badge component for both channels and private messages - */ -@Composable -private fun UnreadBadge( - count: Int, - colorScheme: ColorScheme, - modifier: Modifier = Modifier -) { - if (count > 0) { - Box( - modifier = modifier - .background( - color = Color(0xFFFFD700), // Yellow color - shape = RoundedCornerShape(10.dp) - ) - .padding(horizontal = 2.dp, vertical = 0.dp) - .defaultMinSize(minWidth = 14.dp, minHeight = 14.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = if (count > 99) "99+" else count.toString(), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ), - color = Color.Black // Black text on yellow background - ) - } - } -} - -/** - * Convert RSSI value (dBm) to signal strength percentage (0-100) - * RSSI typically ranges from -30 (excellent) to -100 (very poor) - * Maps to 0-100 scale where: - * - 0-32: No signal (0 bars) - * - 33-65: Weak (1 bar) - * - 66-98: Good (2 bars) - * - 99-100: Excellent (3 bars) - */ -private fun convertRSSIToSignalStrength(rssi: Int?): Int { - if (rssi == null) return 0 - - return when { - rssi >= -40 -> 100 // Excellent signal - rssi >= -55 -> 85 // Very good signal - rssi >= -70 -> 70 // Good signal - rssi >= -85 -> 50 // Fair signal - rssi >= -100 -> 25 // Poor signal - else -> 0 // Very poor or no signal - } -}