mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 06:45:19 +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>
701 lines
27 KiB
Kotlin
701 lines
27 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
|
|
import android.util.Log
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.horizontalScroll
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.text.BasicTextField
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
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.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.SolidColor
|
|
import androidx.compose.ui.platform.LocalFocusManager
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.res.stringResource
|
|
import com.bitchat.android.R
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.input.ImeAction
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
|
import androidx.compose.foundation.Canvas
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
|
|
/**
|
|
* Header components for ChatScreen
|
|
* Extracted from ChatScreen.kt for better organization
|
|
*/
|
|
|
|
|
|
/**
|
|
* Reactive helper to compute favorite state from fingerprint mapping
|
|
* This eliminates the need for static isFavorite parameters and makes
|
|
* the UI reactive to fingerprint manager changes
|
|
*/
|
|
@Composable
|
|
fun isFavoriteReactive(
|
|
peerID: String,
|
|
peerFingerprints: Map<String, String>,
|
|
favoritePeers: Set<String>
|
|
): Boolean {
|
|
return remember(peerID, peerFingerprints, favoritePeers) {
|
|
val fingerprint = peerFingerprints[peerID]
|
|
fingerprint != null && favoritePeers.contains(fingerprint)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun TorStatusDot(
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
|
|
val torStatus by torProvider.statusFlow.collectAsState()
|
|
|
|
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
|
val dotColor = when {
|
|
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
|
|
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
|
|
else -> Color.Red // Red - error/disconnected
|
|
}
|
|
Canvas(
|
|
modifier = modifier
|
|
) {
|
|
val radius = size.minDimension / 2
|
|
drawCircle(
|
|
color = dotColor,
|
|
radius = radius,
|
|
center = Offset(size.width / 2, size.height / 2)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun NoiseSessionIcon(
|
|
sessionState: String?,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val (icon, color, contentDescription) = when (sessionState) {
|
|
"uninitialized" -> Triple(
|
|
Icons.Outlined.NoEncryption,
|
|
Color(0x87878700), // Grey - ready to establish
|
|
stringResource(R.string.cd_ready_for_handshake)
|
|
)
|
|
"handshaking" -> Triple(
|
|
Icons.Outlined.Sync,
|
|
Color(0x87878700), // Grey - in progress
|
|
stringResource(R.string.cd_handshake_in_progress)
|
|
)
|
|
"established" -> Triple(
|
|
Icons.Filled.Lock,
|
|
Color(0xFFFF9500), // Orange - secure
|
|
stringResource(R.string.cd_encrypted)
|
|
)
|
|
else -> { // "failed" or any other state
|
|
Triple(
|
|
Icons.Outlined.Warning,
|
|
Color(0xFFFF4444), // Red - error
|
|
stringResource(R.string.cd_handshake_failed)
|
|
)
|
|
}
|
|
}
|
|
|
|
Icon(
|
|
imageVector = icon,
|
|
contentDescription = contentDescription,
|
|
modifier = modifier,
|
|
tint = color
|
|
)
|
|
}
|
|
|
|
@Composable
|
|
fun NicknameEditor(
|
|
value: String,
|
|
onValueChange: (String) -> Unit,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val focusManager = LocalFocusManager.current
|
|
val scrollState = rememberScrollState()
|
|
|
|
// Auto-scroll to end when text changes (simulates cursor following)
|
|
LaunchedEffect(value) {
|
|
scrollState.animateScrollTo(scrollState.maxValue)
|
|
}
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = modifier
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.at_symbol),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = colorScheme.primary.copy(alpha = 0.8f)
|
|
)
|
|
|
|
BasicTextField(
|
|
value = value,
|
|
onValueChange = onValueChange,
|
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
|
color = colorScheme.primary,
|
|
fontFamily = FontFamily.Monospace
|
|
),
|
|
cursorBrush = SolidColor(colorScheme.primary),
|
|
singleLine = true,
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions = KeyboardActions(
|
|
onDone = {
|
|
focusManager.clearFocus()
|
|
}
|
|
),
|
|
modifier = Modifier
|
|
.widthIn(max = 120.dp)
|
|
.horizontalScroll(scrollState)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun PeerCounter(
|
|
connectedPeers: List<String>,
|
|
joinedChannels: Set<String>,
|
|
hasUnreadChannels: Map<String, Int>,
|
|
isConnected: Boolean,
|
|
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
|
geohashPeople: List<GeoPerson>,
|
|
onClick: () -> Unit,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
// Compute channel-aware people count and color (matches iOS logic exactly)
|
|
val (peopleCount, countColor) = when (selectedLocationChannel) {
|
|
is com.bitchat.android.geohash.ChannelID.Location -> {
|
|
// Geohash channel: show geohash participants
|
|
val count = geohashPeople.size
|
|
val green = Color(0xFF00C851) // Standard green
|
|
Pair(count, if (count > 0) green else Color.Gray)
|
|
}
|
|
is com.bitchat.android.geohash.ChannelID.Mesh,
|
|
null -> {
|
|
// Mesh channel: show Bluetooth-connected peers (excluding self)
|
|
val count = connectedPeers.size
|
|
val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh
|
|
Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray)
|
|
}
|
|
}
|
|
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Default.Group,
|
|
contentDescription = when (selectedLocationChannel) {
|
|
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
|
|
else -> stringResource(R.string.cd_connected_peers)
|
|
},
|
|
modifier = Modifier.size(16.dp),
|
|
tint = countColor
|
|
)
|
|
Spacer(modifier = Modifier.width(4.dp))
|
|
|
|
Text(
|
|
text = "$peopleCount",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = countColor,
|
|
fontSize = 16.sp,
|
|
fontWeight = FontWeight.Medium
|
|
)
|
|
|
|
if (joinedChannels.isNotEmpty()) {
|
|
Text(
|
|
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = if (isConnected) Color(0xFF00C851) else Color.Red,
|
|
fontSize = 16.sp,
|
|
fontWeight = FontWeight.Medium
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun ChatHeaderContent(
|
|
selectedPrivatePeer: String?,
|
|
currentChannel: String?,
|
|
nickname: String,
|
|
viewModel: ChatViewModel,
|
|
onBackClick: () -> Unit,
|
|
onSidebarClick: () -> Unit,
|
|
onTripleClick: () -> Unit,
|
|
onShowAppInfo: () -> Unit,
|
|
onLocationChannelsClick: () -> Unit,
|
|
onLocationNotesClick: () -> Unit
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
when {
|
|
selectedPrivatePeer != null -> {
|
|
// Private chat header - Fully reactive state tracking
|
|
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
|
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
|
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
|
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
|
|
|
// Reactive favorite computation - no more static lookups!
|
|
val isFavorite = isFavoriteReactive(
|
|
peerID = selectedPrivatePeer,
|
|
peerFingerprints = peerFingerprints,
|
|
favoritePeers = favoritePeers
|
|
)
|
|
val sessionState = peerSessionStates[selectedPrivatePeer]
|
|
|
|
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
|
|
|
|
// Pass geohash context and people for NIP-17 chat title formatting
|
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
|
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
|
|
|
PrivateChatHeader(
|
|
peerID = selectedPrivatePeer,
|
|
peerNicknames = peerNicknames,
|
|
isFavorite = isFavorite,
|
|
sessionState = sessionState,
|
|
selectedLocationChannel = selectedLocationChannel,
|
|
geohashPeople = geohashPeople,
|
|
onBackClick = onBackClick,
|
|
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
|
|
viewModel = viewModel
|
|
)
|
|
}
|
|
currentChannel != null -> {
|
|
// Channel header
|
|
ChannelHeader(
|
|
channel = currentChannel,
|
|
onBackClick = onBackClick,
|
|
onLeaveChannel = { viewModel.leaveChannel(currentChannel) },
|
|
onSidebarClick = onSidebarClick
|
|
)
|
|
}
|
|
else -> {
|
|
// Main header
|
|
MainHeader(
|
|
nickname = nickname,
|
|
onNicknameChange = viewModel::setNickname,
|
|
onTitleClick = onShowAppInfo,
|
|
onTripleTitleClick = onTripleClick,
|
|
onSidebarClick = onSidebarClick,
|
|
onLocationChannelsClick = onLocationChannelsClick,
|
|
onLocationNotesClick = onLocationNotesClick,
|
|
viewModel = viewModel
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun PrivateChatHeader(
|
|
peerID: String,
|
|
peerNicknames: Map<String, String>,
|
|
isFavorite: Boolean,
|
|
sessionState: String?,
|
|
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
|
geohashPeople: List<GeoPerson>,
|
|
onBackClick: () -> Unit,
|
|
onToggleFavorite: () -> Unit,
|
|
viewModel: ChatViewModel
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
|
// Determine mutual favorite state for this peer (supports mesh ephemeral 16-hex via favorites lookup)
|
|
val isMutualFavorite = remember(peerID, peerNicknames) {
|
|
try {
|
|
if (isNostrDM) return@remember false
|
|
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
|
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true
|
|
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true
|
|
} else false
|
|
} catch (_: Exception) { false }
|
|
}
|
|
|
|
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
|
|
val titleText: String = if (isNostrDM) {
|
|
// For geohash DMs, get the actual source geohash and proper display name
|
|
val (conversationGeohash, baseName) = try {
|
|
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
|
|
repoField.isAccessible = true
|
|
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
|
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
|
|
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
|
|
val displayName = if (fullPubkey.isNotEmpty()) {
|
|
repo.displayNameForGeohashConversation(fullPubkey, gh)
|
|
} else {
|
|
peerNicknames[peerID] ?: "unknown"
|
|
}
|
|
Pair(gh, displayName)
|
|
} catch (e: Exception) {
|
|
Pair("geohash", peerNicknames[peerID] ?: "unknown")
|
|
}
|
|
|
|
"#$conversationGeohash/@$baseName"
|
|
} else {
|
|
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
|
peerNicknames[peerID] ?: run {
|
|
val titleFromFavorites = try {
|
|
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
|
val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname
|
|
} else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname
|
|
} else null
|
|
} catch (_: Exception) { null }
|
|
titleFromFavorites ?: peerID.take(12)
|
|
}
|
|
}
|
|
|
|
Box(modifier = Modifier.fillMaxWidth()) {
|
|
// Back button - positioned all the way to the left with minimal margin
|
|
Button(
|
|
onClick = onBackClick,
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = Color.Transparent,
|
|
contentColor = colorScheme.primary
|
|
),
|
|
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
|
|
modifier = Modifier
|
|
.align(Alignment.CenterStart)
|
|
.offset(x = (-8).dp) // Move even further left to minimize margin
|
|
) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.ArrowBack,
|
|
contentDescription = stringResource(R.string.back),
|
|
modifier = Modifier.size(16.dp),
|
|
tint = colorScheme.primary
|
|
)
|
|
Spacer(modifier = Modifier.width(4.dp))
|
|
Text(
|
|
text = stringResource(R.string.chat_back),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = colorScheme.primary
|
|
)
|
|
}
|
|
}
|
|
|
|
// Title - perfectly centered regardless of other elements
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.align(Alignment.Center)
|
|
) {
|
|
|
|
Text(
|
|
text = titleText,
|
|
style = MaterialTheme.typography.titleMedium,
|
|
color = Color(0xFFFF9500) // Orange
|
|
)
|
|
|
|
Spacer(modifier = Modifier.width(4.dp))
|
|
|
|
// Show a globe when chatting via Nostr alias, or when mesh session not established but mutual favorite exists
|
|
val showGlobe = isNostrDM || (sessionState != "established" && isMutualFavorite)
|
|
if (showGlobe) {
|
|
Icon(
|
|
imageVector = Icons.Outlined.Public,
|
|
contentDescription = stringResource(R.string.cd_nostr_reachable),
|
|
modifier = Modifier.size(14.dp),
|
|
tint = Color(0xFF9B59B6) // Purple like iOS
|
|
)
|
|
} else {
|
|
NoiseSessionIcon(
|
|
sessionState = sessionState,
|
|
modifier = Modifier.size(14.dp)
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
// Favorite button - positioned on the right
|
|
IconButton(
|
|
onClick = {
|
|
Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
|
onToggleFavorite()
|
|
},
|
|
modifier = Modifier.align(Alignment.CenterEnd)
|
|
) {
|
|
Icon(
|
|
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
|
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
|
|
modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon
|
|
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ChannelHeader(
|
|
channel: String,
|
|
onBackClick: () -> Unit,
|
|
onLeaveChannel: () -> Unit,
|
|
onSidebarClick: () -> Unit
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
Box(modifier = Modifier.fillMaxWidth()) {
|
|
// Back button - positioned all the way to the left with minimal margin
|
|
Button(
|
|
onClick = onBackClick,
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = Color.Transparent,
|
|
contentColor = colorScheme.primary
|
|
),
|
|
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
|
|
modifier = Modifier
|
|
.align(Alignment.CenterStart)
|
|
.offset(x = (-8).dp) // Move even further left to minimize margin
|
|
) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.ArrowBack,
|
|
contentDescription = stringResource(R.string.back),
|
|
modifier = Modifier.size(16.dp),
|
|
tint = colorScheme.primary
|
|
)
|
|
Spacer(modifier = Modifier.width(4.dp))
|
|
Text(
|
|
text = stringResource(R.string.chat_back),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = colorScheme.primary
|
|
)
|
|
}
|
|
}
|
|
|
|
// Title - perfectly centered regardless of other elements
|
|
Text(
|
|
text = stringResource(R.string.chat_channel_prefix, channel),
|
|
style = MaterialTheme.typography.titleMedium,
|
|
color = Color(0xFFFF9500), // Orange to match input field
|
|
modifier = Modifier
|
|
.align(Alignment.Center)
|
|
.clickable { onSidebarClick() }
|
|
)
|
|
|
|
// Leave button - positioned on the right
|
|
TextButton(
|
|
onClick = onLeaveChannel,
|
|
modifier = Modifier.align(Alignment.CenterEnd)
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.chat_leave),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = Color.Red
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun MainHeader(
|
|
nickname: String,
|
|
onNicknameChange: (String) -> Unit,
|
|
onTitleClick: () -> Unit,
|
|
onTripleTitleClick: () -> Unit,
|
|
onSidebarClick: () -> Unit,
|
|
onLocationChannelsClick: () -> Unit,
|
|
onLocationNotesClick: () -> Unit,
|
|
viewModel: ChatViewModel
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
|
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
|
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
|
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
|
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
|
|
|
// Bookmarks store for current geohash toggle (iOS parity)
|
|
val context = androidx.compose.ui.platform.LocalContext.current
|
|
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
|
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
|
|
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxHeight(),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Text(
|
|
text = stringResource(R.string.app_brand),
|
|
style = MaterialTheme.typography.headlineSmall,
|
|
color = colorScheme.primary,
|
|
modifier = Modifier.singleOrTripleClickable(
|
|
onSingleClick = onTitleClick,
|
|
onTripleClick = onTripleTitleClick
|
|
)
|
|
)
|
|
|
|
Spacer(modifier = Modifier.width(2.dp))
|
|
|
|
NicknameEditor(
|
|
value = nickname,
|
|
onValueChange = onNicknameChange
|
|
)
|
|
}
|
|
|
|
// Right section with location channels button and peer counter
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
|
) {
|
|
|
|
// Unread private messages badge (click to open most recent DM)
|
|
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
|
// Render icon directly to avoid symbol resolution issues
|
|
Icon(
|
|
imageVector = Icons.Filled.Email,
|
|
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
|
modifier = Modifier
|
|
.size(16.dp)
|
|
.clickable { viewModel.openLatestUnreadPrivateChat() },
|
|
tint = Color(0xFFFF9500)
|
|
)
|
|
}
|
|
|
|
// Location channels button (matching iOS implementation) and bookmark grouped tightly
|
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
|
|
LocationChannelsButton(
|
|
viewModel = viewModel,
|
|
onClick = onLocationChannelsClick
|
|
)
|
|
|
|
// Bookmark toggle for current geohash (not shown for mesh)
|
|
val currentGeohash: String? = when (val sc = selectedLocationChannel) {
|
|
is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
|
|
else -> null
|
|
}
|
|
if (currentGeohash != null) {
|
|
val isBookmarked = bookmarks.contains(currentGeohash)
|
|
Box(
|
|
modifier = Modifier
|
|
.padding(start = 2.dp) // minimal gap between geohash and bookmark
|
|
.size(20.dp)
|
|
.clickable { bookmarksStore.toggle(currentGeohash) },
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Icon(
|
|
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
|
|
contentDescription = stringResource(R.string.cd_toggle_bookmark),
|
|
tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
|
modifier = Modifier.size(16.dp)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Location Notes button (extracted to separate component)
|
|
LocationNotesButton(
|
|
viewModel = viewModel,
|
|
onClick = onLocationNotesClick
|
|
)
|
|
|
|
// Tor status dot when Tor is enabled
|
|
TorStatusDot(
|
|
modifier = Modifier
|
|
.size(8.dp)
|
|
.padding(start = 0.dp, end = 2.dp)
|
|
)
|
|
|
|
// PoW status indicator
|
|
PoWStatusIndicator(
|
|
modifier = Modifier,
|
|
style = PoWIndicatorStyle.COMPACT
|
|
)
|
|
Spacer(modifier = Modifier.width(2.dp))
|
|
PeerCounter(
|
|
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
|
joinedChannels = joinedChannels,
|
|
hasUnreadChannels = hasUnreadChannels,
|
|
isConnected = isConnected,
|
|
selectedLocationChannel = selectedLocationChannel,
|
|
geohashPeople = geohashPeople,
|
|
onClick = onSidebarClick
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun LocationChannelsButton(
|
|
viewModel: ChatViewModel,
|
|
onClick: () -> Unit
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
// Get current channel selection from location manager
|
|
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
|
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
|
|
|
val (badgeText, badgeColor) = when (selectedChannel) {
|
|
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
|
"#mesh" to Color(0xFF007AFF) // iOS blue for mesh
|
|
}
|
|
is com.bitchat.android.geohash.ChannelID.Location -> {
|
|
val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash
|
|
"#$geohash" to Color(0xFF00C851) // Green for location
|
|
}
|
|
null -> "#mesh" to Color(0xFF007AFF) // Default to mesh
|
|
}
|
|
|
|
Button(
|
|
onClick = onClick,
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = Color.Transparent,
|
|
contentColor = badgeColor
|
|
),
|
|
contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp)
|
|
) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(
|
|
text = badgeText,
|
|
style = MaterialTheme.typography.bodyMedium.copy(
|
|
fontFamily = FontFamily.Monospace
|
|
),
|
|
color = badgeColor,
|
|
maxLines = 1
|
|
)
|
|
|
|
// Teleportation indicator (like iOS)
|
|
if (teleported) {
|
|
Spacer(modifier = Modifier.width(2.dp))
|
|
Icon(
|
|
imageVector = Icons.Default.PinDrop,
|
|
contentDescription = stringResource(R.string.cd_teleported),
|
|
modifier = Modifier.size(12.dp),
|
|
tint = badgeColor
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|