mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 08:05:22 +00:00
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025
* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025
* refactor: new close button like ios(but not liquid glass)
* Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025
* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025
* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025
* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025
* Automated update of relay data - Sun Nov 2 06:22:16 UTC 2025
* Automated update of relay data - Sun Nov 9 06:21:43 UTC 2025
* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025
* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025
* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025
* Chore: Remove unused `lifecycle-livedata-ktx` dependency
This commit removes the `androidx.lifecycle:lifecycle-livedata-ktx` library from the project's dependencies.
The `[libraries]` and `[bundles]` sections in `gradle/libs.versions.toml` have been updated to reflect this removal, as the dependency is no longer in use.
* Refactor: Remove unused `runtime-livedata` dependency
* Refactor: Migrate `LocationChannelManager` and `GeohashBookmarksStore` to StateFlow
This commit refactors `LocationChannelManager` and `GeohashBookmarksStore` to use `StateFlow` instead of `LiveData` for managing and exposing their state. This change aligns with modern Android development practices and improves testability.
**Key Changes:**
- **`LocationChannelManager`**:
- All `MutableLiveData` properties (`permissionState`, `availableChannels`, `selectedChannel`, etc.) have been replaced with `MutableStateFlow`.
- Consumers now access these properties as `StateFlow`.
- State updates have been changed from `postValue()` to direct `.value` assignments, simplifying thread management within the manager which already uses a dedicated coroutine scope.
- **`GeohashBookmarksStore`**:
- `bookmarks` and `bookmarkNames` are now exposed as `StateFlow` instead of `LiveData`.
- State updates similarly use `.value` assignment.
- **Nullability**:
- The non-nullable nature of `StateFlow`'s value reduces the need for null-checks in both the manager classes and their consumers, leading to safer code.
* Refactor: Migrate from LiveData to StateFlow for Nostr components
This commit replaces `LiveData` with `StateFlow` across core Nostr-related classes to align with modern Android architecture and improve state management. This change affects `NostrClient`, `NostrRelayManager`, `LocationNotesManager`, and `GeohashRepository`.
**Key Changes:**
- **`NostrClient`**:
- `isInitialized` and `currentNpub` are now `StateFlow` instead of `LiveData`.
- `relayConnectionStatus` and `relayInfo` now return `StateFlow` from `NostrRelayManager`.
- **`NostrRelayManager`**:
- Public properties `relays` and `isConnected` are migrated from `MutableLiveData` to `MutableStateFlow`.
- Updates are now pushed using `.value` instead of `.postValue()`.
- **`LocationNotesManager`**:
- All public `LiveData` properties (`notes`, `geohash`, `initialLoadComplete`, `state`, `errorMessage`) are converted to `StateFlow`.
- The class documentation is updated to reflect the use of `StateFlow`.
- **`GeohashRepository`**:
- Methods `updateGeohashPeople` and `updateReactiveParticipantCounts` now call `set...` methods on the `state` object instead of `post...`, reflecting the removal of `LiveData` from the underlying state management.
* Refactor: Migrate ChatState from LiveData to StateFlow
This commit refactors the `ChatState`, `ChatViewModel`, and `GeohashViewModel` to use `StateFlow` instead of `LiveData` for managing and exposing UI state. This migration improves state management by leveraging modern coroutine-based flows.
**Key Changes:**
- **`ChatState.kt`**:
- Replaced all `MutableLiveData` instances with `MutableStateFlow`.
- Exposed state properties as `StateFlow` instead of `LiveData`.
- Removed `MediatorLiveData` for computed properties (`hasUnreadChannels`, `hasUnreadPrivateMessages`) and replaced them with `Flow.combine` to create derivative `StateFlows`.
- Simplified non-nullable `getters` to directly return the `.value` of the `StateFlows`.
- Removed `postValue` helpers that are no longer necessary.
- **`ChatViewModel.kt`**:
- Updated all state properties to be `StateFlow`, reflecting the changes in `ChatState`.
- **`GeohashViewModel.kt`**:
- Changed state properties (`geohashPeople`, `geohashParticipantCounts`, etc.) from `LiveData` to `StateFlow`.
- Replaced `observeForever` on `LiveData` from `LocationChannelManager` with `viewModelScope.launch` blocks that `.collect()` from the underlying flows.
* Refactor: Migrate UI from LiveData to StateFlow
This commit replaces `LiveData.observeAsState()` with `StateFlow.collectAsState()` across various UI components. This change aligns the codebase with modern Android development practices, using Kotlin Flows for reactive UI state management.
No functional changes are intended. The primary goal is to remove the dependency on `androidx.lifecycle.livedata` from the composable functions.
**Affected Components:**
- `ChatScreen`
- `SidebarComponents`
- `ChatHeader`
- `LocationChannelsSheet`
- `LocationNotesSheet`
- `LocationNotesButton`
- `GeohashPeopleList`
- `LocationNotesSheetPresenter`
* Refactor: Use `collectAsStateWithLifecycle` for UI state collection
* Refactor: move CloseButton to core/ui/component
* Refactor: remove AI generated comments
* Refactor: fix combine to map and use WhileSubscribed
* Refactor: Pass CoroutineScope to ChatState
This commit refactors the `ChatState` class to accept a `CoroutineScope` in its constructor instead of creating its own.
**Key Changes:**
- **`ChatState.kt`**: The constructor now requires a `CoroutineScope`. This scope is used for the `stateIn` operators that convert `Flows` into `StateFlows` (`hasUnreadChannels`, `hasUnreadPrivateMessages`), ensuring they operate within the lifecycle of the provided scope.
- **`ChatViewModel.kt`**: The `viewModelScope` is now passed to the `ChatState` constructor during its instantiation. This ties the lifecycle of the state's coroutines directly to the `ViewModel`'s lifecycle.
* Test: Use `TestScope` for coroutines in `CommandProcessorTest`
This commit refactors `CommandProcessorTest` to use a `TestScope` and `UnconfinedTestDispatcher` for managing coroutines.
This ensures that coroutine-based operations within the test are executed in a controlled and predictable manner, improving test reliability. The `coroutineScope` for `CommandProcessor` and the `scope` for `ChatState` are now both configured to use this test-specific scope.
---------
Co-authored-by: GitHub Action <action@github.com>
571 lines
24 KiB
Kotlin
571 lines
24 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.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.res.stringResource
|
|
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
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.collectAsStateWithLifecycle()
|
|
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
|
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
|
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
|
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
|
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
|
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
|
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
|
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
|
|
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
|
|
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
|
|
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
|
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
|
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
|
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
|
|
|
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 showLocationNotesSheet 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.collectAsStateWithLifecycle()
|
|
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
|
|
|
|
// Get location channel info for timeline switching
|
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine whether to show media buttons (only hide in geohash location chats)
|
|
val showMediaButtons = when {
|
|
selectedPrivatePeer != null -> true
|
|
currentChannel != null -> true
|
|
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
|
|
}
|
|
|
|
// 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,
|
|
showMediaButtons = showMediaButtons
|
|
)
|
|
}
|
|
|
|
// 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 },
|
|
onLocationNotesClick = { showLocationNotesSheet = 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 = stringResource(com.bitchat.android.R.string.cd_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 },
|
|
showLocationNotesSheet = showLocationNotesSheet,
|
|
onLocationNotesSheetDismiss = { showLocationNotesSheet = 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,
|
|
showMediaButtons: Boolean
|
|
) {
|
|
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,
|
|
showMediaButtons = showMediaButtons,
|
|
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,
|
|
onLocationNotesClick: () -> Unit
|
|
) {
|
|
val context = androidx.compose.ui.platform.LocalContext.current
|
|
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
|
|
|
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,
|
|
onLocationNotesClick = {
|
|
// Ensure location is loaded before showing sheet
|
|
locationManager.refreshChannels()
|
|
onLocationNotesClick()
|
|
}
|
|
)
|
|
},
|
|
colors = TopAppBarDefaults.topAppBarColors(
|
|
containerColor = Color.Transparent
|
|
),
|
|
modifier = Modifier.height(headerHeight) // Ensure compact header height
|
|
)
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@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,
|
|
showLocationNotesSheet: Boolean,
|
|
onLocationNotesSheetDismiss: () -> 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
|
|
)
|
|
}
|
|
|
|
// Location notes sheet (extracted to separate presenter)
|
|
if (showLocationNotesSheet) {
|
|
LocationNotesSheetPresenter(
|
|
viewModel = viewModel,
|
|
onDismiss = onLocationNotesSheetDismiss
|
|
)
|
|
}
|
|
|
|
// User action sheet
|
|
if (showUserSheet) {
|
|
ChatUserSheet(
|
|
isPresented = showUserSheet,
|
|
onDismiss = onUserSheetDismiss,
|
|
targetNickname = selectedUserForSheet,
|
|
selectedMessage = selectedMessageForSheet,
|
|
viewModel = viewModel
|
|
)
|
|
}
|
|
}
|