mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 07:05:21 +00:00
* tor voice wip * worky BLE a bit * can send sound * remove tor * ui cleanup * recording time * progress bar color * nicknames for audio * onboarding permissions no microphone * may work * fix destionation * extend * refactor voice input component * fix keyboard collapse issue * send images * wip image open * image sending works * wip waveforms * better * better animation * fix cursor for sending audio * image sending animation * image sending animation * full screen image viewer * gossip sync for fragments too * reduce delays * fix keyboard focus * use v2 for file transfers * do not sync fragments * scrollable image viewer * ui * ui adjustments * nicer animation * seek through audio * add spec * add more details to documentation * File sharing E2E: - Add TLV BitchatFilePacket, FileSharingManager - Implement sendFileNote in ChatViewModel - File receive path: save to files/incoming and render [file] messages with FileMessageItem or FileSendingAnimation during transfer - SAF FilePickerButton and dispatcher wiring; image/file choice to follow in MediaPickerOptions - Add FileViewerDialog with system open/save, FileProvider and file_paths - Hook transfer progress to file sending UI - Manifest: READ_MEDIA_* and FileProvider - Fix MessageHandler saving and prefix for non-image payloads - Add helper utils (FileUtils) * kinda wip * fix buttons * files half working * wip file transfer * file packet has 2-byte TLV and chunks. it wokrs but it sucks * clean * remove gossip sync for fragments * fix audio and image rendering * adjust FILE_SIZE TLV size too * cleanup * haptic * private messages media * read receipts for media * use enum for message type not string * delivery ack checks dont push content * check * animation fix * refactor * ui adjustments * comments * refactor * fix crash on send and receive of the same file * refactor notifications * tests
536 lines
22 KiB
Kotlin
536 lines
22 KiB
Kotlin
package com.bitchat.android.ui
|
|
// [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition
|
|
|
|
// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel
|
|
|
|
|
|
import androidx.compose.animation.*
|
|
import androidx.compose.animation.core.*
|
|
import androidx.compose.foundation.*
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.material3.*
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.runtime.livedata.observeAsState
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.ArrowDownward
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.ui.text.TextRange
|
|
import androidx.compose.ui.text.input.TextFieldValue
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.zIndex
|
|
import com.bitchat.android.model.BitchatMessage
|
|
import com.bitchat.android.ui.media.FullScreenImageViewer
|
|
|
|
/**
|
|
* Main ChatScreen - REFACTORED to use component-based architecture
|
|
* This is now a coordinator that orchestrates the following UI components:
|
|
* - ChatHeader: App bar, navigation, peer counter
|
|
* - MessageComponents: Message display and formatting
|
|
* - InputComponents: Message input and command suggestions
|
|
* - SidebarComponents: Navigation drawer with channels and people
|
|
* - AboutSheet: App info and password prompts
|
|
* - ChatUIUtils: Utility functions for formatting and colors
|
|
*/
|
|
@Composable
|
|
fun ChatScreen(viewModel: ChatViewModel) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val messages by viewModel.messages.observeAsState(emptyList())
|
|
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
|
val nickname by viewModel.nickname.observeAsState("")
|
|
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
|
val currentChannel by viewModel.currentChannel.observeAsState()
|
|
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
|
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
|
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)
|
|
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
|
|
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
|
|
|
|
var messageText by remember { mutableStateOf(TextFieldValue("")) }
|
|
var showPasswordPrompt by remember { mutableStateOf(false) }
|
|
var showPasswordDialog by remember { mutableStateOf(false) }
|
|
var passwordInput by remember { mutableStateOf("") }
|
|
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
|
var showUserSheet by remember { mutableStateOf(false) }
|
|
var selectedUserForSheet by remember { mutableStateOf("") }
|
|
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
|
|
var showFullScreenImageViewer by remember { mutableStateOf(false) }
|
|
var viewerImagePaths by remember { mutableStateOf(emptyList<String>()) }
|
|
var initialViewerIndex by remember { mutableStateOf(0) }
|
|
var forceScrollToBottom by remember { mutableStateOf(false) }
|
|
var isScrolledUp by remember { mutableStateOf(false) }
|
|
|
|
// Show password dialog when needed
|
|
LaunchedEffect(showPasswordPrompt) {
|
|
showPasswordDialog = showPasswordPrompt
|
|
}
|
|
|
|
val isConnected by viewModel.isConnected.observeAsState(false)
|
|
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
|
|
|
|
// Get location channel info for timeline switching
|
|
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
|
|
|
// Determine what messages to show based on current context (unified timelines)
|
|
val displayMessages = when {
|
|
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
|
|
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
|
|
else -> {
|
|
val locationChannel = selectedLocationChannel
|
|
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
|
val geokey = "geo:${locationChannel.channel.geohash}"
|
|
channelMessages[geokey] ?: emptyList()
|
|
} else {
|
|
messages // Mesh timeline
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use WindowInsets to handle keyboard properly
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(colorScheme.background) // Extend background to fill entire screen including status bar
|
|
) {
|
|
val headerHeight = 42.dp
|
|
|
|
// Main content area that responds to keyboard/window insets
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets
|
|
.windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded
|
|
) {
|
|
// Header spacer - creates exact space for the floating header (status bar + compact header)
|
|
Spacer(
|
|
modifier = Modifier
|
|
.windowInsetsPadding(WindowInsets.statusBars)
|
|
.height(headerHeight)
|
|
)
|
|
|
|
// Messages area - takes up available space, will compress when keyboard appears
|
|
MessagesList(
|
|
messages = displayMessages,
|
|
currentUserNickname = nickname,
|
|
meshService = viewModel.meshService,
|
|
modifier = Modifier.weight(1f),
|
|
forceScrollToBottom = forceScrollToBottom,
|
|
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
|
onNicknameClick = { fullSenderName ->
|
|
// Single click - mention user in text input
|
|
val currentText = messageText.text
|
|
|
|
// Extract base nickname and hash suffix from full sender name
|
|
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
|
|
|
|
// Check if we're in a geohash channel to include hash suffix
|
|
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
|
val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) {
|
|
// In geohash chat - include the hash suffix from the full display name
|
|
"@$baseName$hashSuffix"
|
|
} else {
|
|
// Regular chat - just the base nickname
|
|
"@$baseName"
|
|
}
|
|
|
|
val newText = when {
|
|
currentText.isEmpty() -> "$mentionText "
|
|
currentText.endsWith(" ") -> "$currentText$mentionText "
|
|
else -> "$currentText $mentionText "
|
|
}
|
|
|
|
messageText = TextFieldValue(
|
|
text = newText,
|
|
selection = TextRange(newText.length)
|
|
)
|
|
},
|
|
onMessageLongPress = { message ->
|
|
// Message long press - open user action sheet with message context
|
|
// Extract base nickname from message sender (contains all necessary info)
|
|
val (baseName, _) = splitSuffix(message.sender)
|
|
selectedUserForSheet = baseName
|
|
selectedMessageForSheet = message
|
|
showUserSheet = true
|
|
},
|
|
onCancelTransfer = { msg ->
|
|
viewModel.cancelMediaSend(msg.id)
|
|
},
|
|
onImageClick = { currentPath, allImagePaths, initialIndex ->
|
|
viewerImagePaths = allImagePaths
|
|
initialViewerIndex = initialIndex
|
|
showFullScreenImageViewer = true
|
|
}
|
|
)
|
|
// Input area - stays at bottom
|
|
// Bridge file share from lower-level input to ViewModel
|
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
|
com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path ->
|
|
viewModel.sendFileNote(peer, channel, path)
|
|
}
|
|
}
|
|
|
|
ChatInputSection(
|
|
messageText = messageText,
|
|
onMessageTextChange = { newText: TextFieldValue ->
|
|
messageText = newText
|
|
viewModel.updateCommandSuggestions(newText.text)
|
|
viewModel.updateMentionSuggestions(newText.text)
|
|
},
|
|
onSend = {
|
|
if (messageText.text.trim().isNotEmpty()) {
|
|
viewModel.sendMessage(messageText.text.trim())
|
|
messageText = TextFieldValue("")
|
|
forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll
|
|
}
|
|
},
|
|
onSendVoiceNote = { peer, onionOrChannel, path ->
|
|
viewModel.sendVoiceNote(peer, onionOrChannel, path)
|
|
},
|
|
onSendImageNote = { peer, onionOrChannel, path ->
|
|
viewModel.sendImageNote(peer, onionOrChannel, path)
|
|
},
|
|
onSendFileNote = { peer, onionOrChannel, path ->
|
|
viewModel.sendFileNote(peer, onionOrChannel, path)
|
|
},
|
|
|
|
showCommandSuggestions = showCommandSuggestions,
|
|
commandSuggestions = commandSuggestions,
|
|
showMentionSuggestions = showMentionSuggestions,
|
|
mentionSuggestions = mentionSuggestions,
|
|
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
|
|
val commandText = viewModel.selectCommandSuggestion(suggestion)
|
|
messageText = TextFieldValue(
|
|
text = commandText,
|
|
selection = TextRange(commandText.length)
|
|
)
|
|
},
|
|
onMentionSuggestionClick = { mention: String ->
|
|
val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text)
|
|
messageText = TextFieldValue(
|
|
text = mentionText,
|
|
selection = TextRange(mentionText.length)
|
|
)
|
|
},
|
|
selectedPrivatePeer = selectedPrivatePeer,
|
|
currentChannel = currentChannel,
|
|
nickname = nickname,
|
|
colorScheme = colorScheme
|
|
)
|
|
}
|
|
|
|
// Floating header - positioned absolutely at top, ignores keyboard
|
|
ChatFloatingHeader(
|
|
headerHeight = headerHeight,
|
|
selectedPrivatePeer = selectedPrivatePeer,
|
|
currentChannel = currentChannel,
|
|
nickname = nickname,
|
|
viewModel = viewModel,
|
|
colorScheme = colorScheme,
|
|
onSidebarToggle = { viewModel.showSidebar() },
|
|
onShowAppInfo = { viewModel.showAppInfo() },
|
|
onPanicClear = { viewModel.panicClearAllData() },
|
|
onLocationChannelsClick = { showLocationChannelsSheet = true }
|
|
)
|
|
|
|
// Divider under header - positioned after status bar + header height
|
|
HorizontalDivider(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.windowInsetsPadding(WindowInsets.statusBars)
|
|
.offset(y = headerHeight)
|
|
.zIndex(1f),
|
|
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,
|
|
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
|
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
|
modifier = Modifier
|
|
.align(Alignment.BottomEnd)
|
|
.padding(end = 16.dp, bottom = 64.dp)
|
|
.zIndex(1.5f)
|
|
.windowInsetsPadding(WindowInsets.navigationBars)
|
|
.windowInsetsPadding(WindowInsets.ime)
|
|
) {
|
|
Surface(
|
|
shape = CircleShape,
|
|
color = colorScheme.background,
|
|
tonalElevation = 3.dp,
|
|
shadowElevation = 6.dp,
|
|
border = BorderStroke(2.dp, Color(0xFF00C851))
|
|
) {
|
|
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
|
|
Icon(
|
|
imageVector = Icons.Filled.ArrowDownward,
|
|
contentDescription = "Scroll to bottom",
|
|
tint = Color(0xFF00C851)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
if (showFullScreenImageViewer) {
|
|
FullScreenImageViewer(
|
|
imagePaths = viewerImagePaths,
|
|
initialIndex = initialViewerIndex,
|
|
onClose = { showFullScreenImageViewer = false }
|
|
)
|
|
}
|
|
|
|
// Dialogs and Sheets
|
|
ChatDialogs(
|
|
showPasswordDialog = showPasswordDialog,
|
|
passwordPromptChannel = passwordPromptChannel,
|
|
passwordInput = passwordInput,
|
|
onPasswordChange = { passwordInput = it },
|
|
onPasswordConfirm = {
|
|
if (passwordInput.isNotEmpty()) {
|
|
val success = viewModel.joinChannel(passwordPromptChannel!!, passwordInput)
|
|
if (success) {
|
|
showPasswordDialog = false
|
|
passwordInput = ""
|
|
}
|
|
}
|
|
},
|
|
onPasswordDismiss = {
|
|
showPasswordDialog = false
|
|
passwordInput = ""
|
|
},
|
|
showAppInfo = showAppInfo,
|
|
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
|
showLocationChannelsSheet = showLocationChannelsSheet,
|
|
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
|
showUserSheet = showUserSheet,
|
|
onUserSheetDismiss = {
|
|
showUserSheet = false
|
|
selectedMessageForSheet = null // Reset message when dismissing
|
|
},
|
|
selectedUserForSheet = selectedUserForSheet,
|
|
selectedMessageForSheet = selectedMessageForSheet,
|
|
viewModel = viewModel
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
private fun ChatInputSection(
|
|
messageText: TextFieldValue,
|
|
onMessageTextChange: (TextFieldValue) -> Unit,
|
|
onSend: () -> Unit,
|
|
onSendVoiceNote: (String?, String?, String) -> Unit,
|
|
onSendImageNote: (String?, String?, String) -> Unit,
|
|
onSendFileNote: (String?, String?, String) -> Unit,
|
|
showCommandSuggestions: Boolean,
|
|
commandSuggestions: List<CommandSuggestion>,
|
|
showMentionSuggestions: Boolean,
|
|
mentionSuggestions: List<String>,
|
|
onCommandSuggestionClick: (CommandSuggestion) -> Unit,
|
|
onMentionSuggestionClick: (String) -> Unit,
|
|
selectedPrivatePeer: String?,
|
|
currentChannel: String?,
|
|
nickname: String,
|
|
colorScheme: ColorScheme
|
|
) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
color = colorScheme.background
|
|
) {
|
|
Column {
|
|
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
|
// Command suggestions box
|
|
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
|
CommandSuggestionsBox(
|
|
suggestions = commandSuggestions,
|
|
onSuggestionClick = onCommandSuggestionClick,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
|
}
|
|
// Mention suggestions box
|
|
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
|
|
MentionSuggestionsBox(
|
|
suggestions = mentionSuggestions,
|
|
onSuggestionClick = onMentionSuggestionClick,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
|
}
|
|
MessageInput(
|
|
value = messageText,
|
|
onValueChange = onMessageTextChange,
|
|
onSend = onSend,
|
|
onSendVoiceNote = onSendVoiceNote,
|
|
onSendImageNote = onSendImageNote,
|
|
onSendFileNote = onSendFileNote,
|
|
selectedPrivatePeer = selectedPrivatePeer,
|
|
currentChannel = currentChannel,
|
|
nickname = nickname,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
private fun ChatFloatingHeader(
|
|
headerHeight: Dp,
|
|
selectedPrivatePeer: String?,
|
|
currentChannel: String?,
|
|
nickname: String,
|
|
viewModel: ChatViewModel,
|
|
colorScheme: ColorScheme,
|
|
onSidebarToggle: () -> Unit,
|
|
onShowAppInfo: () -> Unit,
|
|
onPanicClear: () -> Unit,
|
|
onLocationChannelsClick: () -> Unit
|
|
) {
|
|
Surface(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.zIndex(1f)
|
|
.windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area
|
|
color = colorScheme.background // Solid background color extending into status bar
|
|
) {
|
|
TopAppBar(
|
|
title = {
|
|
ChatHeaderContent(
|
|
selectedPrivatePeer = selectedPrivatePeer,
|
|
currentChannel = currentChannel,
|
|
nickname = nickname,
|
|
viewModel = viewModel,
|
|
onBackClick = {
|
|
when {
|
|
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
|
currentChannel != null -> viewModel.switchToChannel(null)
|
|
}
|
|
},
|
|
onSidebarClick = onSidebarToggle,
|
|
onTripleClick = onPanicClear,
|
|
onShowAppInfo = onShowAppInfo,
|
|
onLocationChannelsClick = onLocationChannelsClick
|
|
)
|
|
},
|
|
colors = TopAppBarDefaults.topAppBarColors(
|
|
containerColor = Color.Transparent
|
|
),
|
|
modifier = Modifier.height(headerHeight) // Ensure compact header height
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ChatDialogs(
|
|
showPasswordDialog: Boolean,
|
|
passwordPromptChannel: String?,
|
|
passwordInput: String,
|
|
onPasswordChange: (String) -> Unit,
|
|
onPasswordConfirm: () -> Unit,
|
|
onPasswordDismiss: () -> Unit,
|
|
showAppInfo: Boolean,
|
|
onAppInfoDismiss: () -> Unit,
|
|
showLocationChannelsSheet: Boolean,
|
|
onLocationChannelsSheetDismiss: () -> Unit,
|
|
showUserSheet: Boolean,
|
|
onUserSheetDismiss: () -> Unit,
|
|
selectedUserForSheet: String,
|
|
selectedMessageForSheet: BitchatMessage?,
|
|
viewModel: ChatViewModel
|
|
) {
|
|
// Password dialog
|
|
PasswordPromptDialog(
|
|
show = showPasswordDialog,
|
|
channelName = passwordPromptChannel,
|
|
passwordInput = passwordInput,
|
|
onPasswordChange = onPasswordChange,
|
|
onConfirm = onPasswordConfirm,
|
|
onDismiss = onPasswordDismiss
|
|
)
|
|
|
|
// About sheet
|
|
var showDebugSheet by remember { mutableStateOf(false) }
|
|
AboutSheet(
|
|
isPresented = showAppInfo,
|
|
onDismiss = onAppInfoDismiss,
|
|
onShowDebug = { showDebugSheet = true }
|
|
)
|
|
if (showDebugSheet) {
|
|
com.bitchat.android.ui.debug.DebugSettingsSheet(
|
|
isPresented = showDebugSheet,
|
|
onDismiss = { showDebugSheet = false },
|
|
meshService = viewModel.meshService
|
|
)
|
|
}
|
|
|
|
// Location channels sheet
|
|
if (showLocationChannelsSheet) {
|
|
LocationChannelsSheet(
|
|
isPresented = showLocationChannelsSheet,
|
|
onDismiss = onLocationChannelsSheetDismiss,
|
|
viewModel = viewModel
|
|
)
|
|
}
|
|
|
|
// User action sheet
|
|
if (showUserSheet) {
|
|
ChatUserSheet(
|
|
isPresented = showUserSheet,
|
|
onDismiss = onUserSheetDismiss,
|
|
targetNickname = selectedUserForSheet,
|
|
selectedMessage = selectedMessageForSheet,
|
|
viewModel = viewModel
|
|
)
|
|
}
|
|
}
|