mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 21:25:20 +00:00
* feat: add product flavors and TorProvider abstraction Introduces build flavors to separate Tor functionality from the standard build, reducing APK size for users who don't need Tor. - Creates `standard` and `tor` product flavors. - The `standard` flavor is the default, lightweight build. - The `tor` flavor includes the Arti (Tor) dependency and is identified by the `.tor` application ID suffix. - Adds a `TorProvider` interface and a `TorProviderFactory` to abstract Tor implementation details between flavors. Prepares architecture for optional Tor support to reduce APK size from 142MB to ~4-5MB for standard builds Related to #454 * Refactor: implement StandardTorProvider and RealTorProvider This commit refactors the Tor integration by introducing a `TorProvider` interface and creating separate implementations for 'tor' and 'standard' product flavors. - The original `TorManager` singleton has been moved into `RealTorProvider` for the 'tor' flavor. - A no-op `StandardTorProvider` is introduced for the 'standard' flavor, which reports Tor as unavailable. - A `TorProviderFactory` is used to create the appropriate provider at runtime based on the build variant. * refactor: migrate to TorProvider abstraction Replaced direct calls to the static `TorManager` with an instance obtained from `TorProviderFactory`. This change allows for different Tor implementations based on build flavors, improving modularity and abstracting the Tor provider logic. Updated `BitchatApplication`, `ChatHeader`, `OkHttpProvider`, and `AboutSheet` to use the new factory pattern for accessing Tor functionalities. * build: add flavor-specific ProGuard rules and CI/CD - Split ProGuard rules: base, standard-specific, tor-specific - Move Arti/Guardian Project rules to proguard-tor.pro - Update CI/CD workflow to build both flavors - Add separate artifact uploads for each flavor - Add descriptive release notes template CI now builds both standard (~4-5MB) and tor (~140MB) APKs." resolves #454 * refactor: centralize network reset logic Extracts the repeated network connection reset logic into a new private function `resetNetworkConnections()`. This change also replaces direct `_statusFlow.value = ...` assignments with the safer `_statusFlow.update { ... }` function to prevent race conditions. * ci: run separate build steps for flavors * feat: disable Tor toggle if not available in build * ci: parallelize builds and add conditional tor lint Optimizes CI workflow: - Parallel matrix builds (4 runners instead of sequential) - Conditional tor lint only when app/src/tor/ changes in PRs - Merged test+lint jobs to reduce setup overhead Reduces CI time by ~50% (8-11 min vs 18-26 min) * refactor: Unify Tor implementation and remove build flavors This commit refactors the Tor integration by removing the `standard` and `tor` product flavors in favor of a single, unified build that always includes a custom-built Arti (Tor) library. Key changes include: * **Removed Build Flavors:** Deleted the `standard` and `tor` product flavors from `build.gradle.kts`, simplifying the build process and CI configuration. * **Unified Tor Manager:** Replaced the `TorProvider` interface and flavor-specific implementations (`StandardTorProvider`, `RealTorProvider`) with a new singleton, `ArtiTorManager`. This class now manages the Arti lifecycle for all builds. * **Custom Arti Wrapper:** Introduced a new `ArtiProxy` class to provide a compatible API wrapper around the custom-built native Arti library (`libarti_android.so`). This replaces the dependency on the external `arti-mobile-ex` library. * **Updated Proguard:** Consolidated and updated Proguard rules into the main `proguard-rules.pro` file to keep the necessary `ArtiTorManager` and native library classes. * **CI/CD Simplification:** Updated GitHub Actions workflows (`release.yml`, `android-build.yml`) to build and release a single APK instead of separate ones for each flavor. * build: Configure ABI filters for debug and release builds For debug builds, include `x86_64` to support emulators. For release builds, only include `arm64-v8a` to minimize the final APK size. * feat: Ignore jniLibs directory This change adds the `app/src/main/jniLibs/` directory to the `.gitignore` file to prevent native libraries from being committed to the repository. * feat: Refine .gitignore for Arti build artifacts Improves the `.gitignore` file by: - Ignoring all `build/` directories except for `tools/arti-build/`. - Adding specific ignores for Arti build artifacts, including the cloned source repository and the Rust build cache directory. * feat: Update arti android native library * feat: add build script and JNI wrapper for Arti Adds a comprehensive build system for creating custom Arti (Tor in Rust) shared libraries for Android. This replaces the dependency on external, outdated AARs with a fully transparent and reproducible build process. Key changes: - Introduces `build-arti.sh`, a script to clone the official Arti repository, apply a JNI wrapper, and build `.so` files for Android. - Adds `ARTI_VERSION` to pin the build to a specific Arti release (v1.7.0). - Implements a new Rust JNI wrapper (`src/lib.rs`) that exposes core functions like `initialize`, `startSocksProxy`, and `stop` to the Android app. - Includes a `Cargo.toml` with release profile optimizations for size (`lto`, `strip`, `opt-level = "z"`). - Provides detailed documentation in `README.md` explaining the build process, prerequisites, and architecture. * build script for mac * consolidate both scripts * improve script --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.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.runtime.livedata.observeAsState
|
|
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
|
|
|
|
/**
|
|
* 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.observeAsState(emptySet())
|
|
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
|
|
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap())
|
|
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
|
|
|
|
// 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.observeAsState()
|
|
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
|
|
|
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.observeAsState(emptyList())
|
|
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
|
|
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
|
val isConnected by viewModel.isConnected.observeAsState(false)
|
|
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
|
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
|
|
|
// 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.observeAsState(emptyList())
|
|
|
|
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.observeAsState()
|
|
val teleported by viewModel.isTeleported.observeAsState(false)
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|