mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 23:45:20 +00:00
Nostr geohash (#276)
* first nostr build * add test file * internet access * fix relay manager * fix serialization * demo service - remove later * fix nostr * event dedupe * dedupe * ui wip * can send messages * subscription works * works * favs * works * delete chat on change * fix mentions * remove autojoin channels * styling * adjust colors * ui changes * live updates working * use local timestamp * message history in background * robust * fixes * nicknames refresh optimization * nostr service * refactor nostr * style * geohash works * centralize colors * refactoring * disable DMs for now: click on peer nickname doesnt open chat list in geohash mode * use local time * less logging * robustness * scroll nickname * adjust some text
This commit is contained in:
@@ -2,7 +2,9 @@ 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
|
||||
@@ -92,6 +94,12 @@ fun NicknameEditor(
|
||||
) {
|
||||
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,
|
||||
@@ -111,13 +119,16 @@ fun NicknameEditor(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
cursorBrush = SolidColor(colorScheme.primary),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
modifier = Modifier.widthIn(max = 100.dp)
|
||||
modifier = Modifier
|
||||
.widthIn(max = 120.dp)
|
||||
.horizontalScroll(scrollState)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -129,11 +140,30 @@ fun PeerCounter(
|
||||
hasUnreadChannels: Map<String, Int>,
|
||||
hasUnreadPrivateMessages: Set<String>,
|
||||
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
|
||||
@@ -167,15 +197,18 @@ fun PeerCounter(
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Group,
|
||||
contentDescription = "Connected peers",
|
||||
contentDescription = when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> "Geohash participants"
|
||||
else -> "Connected peers"
|
||||
},
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isConnected) Color(0xFF00C851) else Color.Red
|
||||
tint = countColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${connectedPeers.size}",
|
||||
text = "$peopleCount",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isConnected) Color(0xFF00C851) else Color.Red,
|
||||
color = countColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
@@ -201,7 +234,8 @@ fun ChatHeaderContent(
|
||||
onBackClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
onTripleClick: () -> Unit,
|
||||
onShowAppInfo: () -> Unit
|
||||
onShowAppInfo: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
@@ -249,6 +283,7 @@ fun ChatHeaderContent(
|
||||
onTitleClick = onShowAppInfo,
|
||||
onTripleTitleClick = onTripleClick,
|
||||
onSidebarClick = onSidebarClick,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
@@ -408,6 +443,7 @@ private fun MainHeader(
|
||||
onTitleClick: () -> Unit,
|
||||
onTripleTitleClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@@ -416,6 +452,8 @@ private fun MainHeader(
|
||||
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())
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -444,13 +482,104 @@ private fun MainHeader(
|
||||
)
|
||||
}
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
|
||||
isConnected = isConnected,
|
||||
onClick = onSidebarClick
|
||||
)
|
||||
// Right section with location channels button and peer counter
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Unread indicator (like iOS)
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
Button(
|
||||
onClick = {
|
||||
// Open most relevant private chat (first unread)
|
||||
val firstUnread = hasUnreadPrivateMessages.firstOrNull()
|
||||
if (firstUnread != null) {
|
||||
viewModel.startPrivateChat(firstUnread)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = Color(0xFFFF9500)
|
||||
),
|
||||
contentPadding = PaddingValues(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = "Open unread private chat",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color(0xFFFF9500)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Location channels button (matching iOS implementation)
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
)
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
|
||||
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(horizontal = 4.dp, vertical = 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))
|
||||
Text(
|
||||
text = "📍",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// Show password dialog when needed
|
||||
LaunchedEffect(showPasswordPrompt) {
|
||||
@@ -135,7 +136,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
colorScheme = colorScheme,
|
||||
onSidebarToggle = { viewModel.showSidebar() },
|
||||
onShowAppInfo = { viewModel.showAppInfo() },
|
||||
onPanicClear = { viewModel.panicClearAllData() }
|
||||
onPanicClear = { viewModel.panicClearAllData() },
|
||||
onLocationChannelsClick = { showLocationChannelsSheet = true }
|
||||
)
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
@@ -177,7 +179,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
// Dialogs and Sheets
|
||||
ChatDialogs(
|
||||
showPasswordDialog = showPasswordDialog,
|
||||
passwordPromptChannel = passwordPromptChannel,
|
||||
@@ -197,7 +199,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
passwordInput = ""
|
||||
},
|
||||
showAppInfo = showAppInfo,
|
||||
onAppInfoDismiss = { viewModel.hideAppInfo() }
|
||||
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
||||
showLocationChannelsSheet = showLocationChannelsSheet,
|
||||
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,7 +275,8 @@ private fun ChatFloatingHeader(
|
||||
colorScheme: ColorScheme,
|
||||
onSidebarToggle: () -> Unit,
|
||||
onShowAppInfo: () -> Unit,
|
||||
onPanicClear: () -> Unit
|
||||
onPanicClear: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -296,7 +302,8 @@ private fun ChatFloatingHeader(
|
||||
},
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -324,7 +331,10 @@ private fun ChatDialogs(
|
||||
onPasswordConfirm: () -> Unit,
|
||||
onPasswordDismiss: () -> Unit,
|
||||
showAppInfo: Boolean,
|
||||
onAppInfoDismiss: () -> Unit
|
||||
onAppInfoDismiss: () -> Unit,
|
||||
showLocationChannelsSheet: Boolean,
|
||||
onLocationChannelsSheetDismiss: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
@@ -341,4 +351,13 @@ private fun ChatDialogs(
|
||||
show = showAppInfo,
|
||||
onDismiss = onAppInfoDismiss
|
||||
)
|
||||
|
||||
// Location channels sheet
|
||||
if (showLocationChannelsSheet) {
|
||||
LocationChannelsSheet(
|
||||
isPresented = showLocationChannelsSheet,
|
||||
onDismiss = onLocationChannelsSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,24 @@ class ChatState {
|
||||
private val _showAppInfo = MutableLiveData<Boolean>(false)
|
||||
val showAppInfo: LiveData<Boolean> = _showAppInfo
|
||||
|
||||
// Location channels state (for Nostr geohash features)
|
||||
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel
|
||||
|
||||
private val _isTeleported = MutableLiveData<Boolean>(false)
|
||||
val isTeleported: LiveData<Boolean> = _isTeleported
|
||||
|
||||
// Geohash people state (iOS-compatible)
|
||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
||||
|
||||
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
||||
|
||||
// Geohash participant counts reactive state (for real-time location channel counts)
|
||||
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap())
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts
|
||||
|
||||
// Unread state computed properties
|
||||
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||
@@ -148,6 +166,9 @@ class ChatState {
|
||||
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
|
||||
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
|
||||
fun getShowAppInfoValue() = _showAppInfo.value ?: false
|
||||
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList()
|
||||
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet()
|
||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap()
|
||||
|
||||
// Setters for state updates
|
||||
fun setMessages(messages: List<BitchatMessage>) {
|
||||
@@ -259,5 +280,25 @@ class ChatState {
|
||||
fun setShowAppInfo(show: Boolean) {
|
||||
_showAppInfo.value = show
|
||||
}
|
||||
|
||||
fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
_selectedLocationChannel.value = channel
|
||||
}
|
||||
|
||||
fun setIsTeleported(teleported: Boolean) {
|
||||
_isTeleported.value = teleported
|
||||
}
|
||||
|
||||
fun setGeohashPeople(people: List<GeoPerson>) {
|
||||
_geohashPeople.value = people
|
||||
}
|
||||
|
||||
fun setTeleportedGeo(teleported: Set<String>) {
|
||||
_teleportedGeo.value = teleported
|
||||
}
|
||||
|
||||
fun setGeohashParticipantCounts(counts: Map<String, Int>) {
|
||||
_geohashParticipantCounts.value = counts
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message as annotated string with proper styling
|
||||
* Format message as annotated string with iOS-style formatting
|
||||
* Timestamp at END, peer colors, hashtag suffix handling
|
||||
*/
|
||||
fun formatMessageAsAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
@@ -41,112 +42,294 @@ fun formatMessageAsAnnotatedString(
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
// Timestamp
|
||||
val timestampColor = if (message.sender == "system") Color.Gray else colorScheme.primary.copy(alpha = 0.7f)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = timestampColor,
|
||||
fontSize = 12.sp
|
||||
))
|
||||
builder.append("[${timeFormatter.format(message.timestamp)}] ")
|
||||
builder.pop()
|
||||
// Determine if this message was sent by self
|
||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
||||
message.sender == currentUserNickname ||
|
||||
message.sender.startsWith("$currentUserNickname#")
|
||||
|
||||
if (message.sender != "system") {
|
||||
// Sender
|
||||
val senderColor = when {
|
||||
message.senderPeerID == meshService.myPeerID -> colorScheme.primary
|
||||
else -> {
|
||||
val peerID = message.senderPeerID
|
||||
val rssi = peerID?.let { meshService.getPeerRSSI()[it] } ?: -60
|
||||
getRSSIColor(rssi)
|
||||
}
|
||||
// Get base color for this peer (iOS-style color assignment)
|
||||
val baseColor = if (isSelf) {
|
||||
Color(0xFFFF9500) // Orange for self (iOS orange)
|
||||
} else {
|
||||
getPeerColor(message, isDark)
|
||||
}
|
||||
|
||||
// Split sender into base name and hashtag suffix
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// Sender prefix "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = senderColor,
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@${message.sender}> ")
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Message content with mentions and hashtags highlighted
|
||||
appendFormattedContent(builder, message.content, message.mentions, currentUserNickname, colorScheme)
|
||||
// Base name
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(baseName)
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix in lighter color (iOS style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix "> "
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("> ")
|
||||
builder.pop()
|
||||
|
||||
// Message content with iOS-style hashtag and mention highlighting
|
||||
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
|
||||
|
||||
// iOS-style timestamp at the END (smaller, grey)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = 10.sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
|
||||
} else {
|
||||
// System message
|
||||
// System message - iOS style
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = 12.sp,
|
||||
fontStyle = FontStyle.Italic
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp for system messages too
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = 10.sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Append formatted content with hashtag and mention highlighting
|
||||
* iOS-style peer color assignment using djb2 hash algorithm
|
||||
* Avoids orange (~30°) reserved for self messages
|
||||
*/
|
||||
private fun appendFormattedContent(
|
||||
fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color {
|
||||
// Create seed from peer identifier (prioritizing stable keys)
|
||||
val seed = when {
|
||||
message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> {
|
||||
// For Nostr peers, use the full key if available, otherwise the peer ID
|
||||
"nostr:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
message.senderPeerID?.length == 16 -> {
|
||||
// For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID
|
||||
"noise:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
message.senderPeerID?.length == 64 -> {
|
||||
// This is already a stable Noise key
|
||||
"noise:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
else -> {
|
||||
// Fallback to sender name
|
||||
message.sender.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
return colorForPeerSeed(seed, isDark)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate consistent peer color using djb2 hash (matches iOS algorithm exactly)
|
||||
*/
|
||||
fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
|
||||
// djb2 hash algorithm (matches iOS implementation)
|
||||
var hash = 5381UL
|
||||
for (byte in seed.toByteArray()) {
|
||||
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
|
||||
}
|
||||
|
||||
var hue = (hash % 360UL).toDouble() / 360.0
|
||||
|
||||
// Avoid orange (~30°) reserved for self (matches iOS logic)
|
||||
val orange = 30.0 / 360.0
|
||||
if (kotlin.math.abs(hue - orange) < 0.05) {
|
||||
hue = (hue + 0.12) % 1.0
|
||||
}
|
||||
|
||||
val saturation = if (isDark) 0.50 else 0.70
|
||||
val brightness = if (isDark) 0.95 else 0.45
|
||||
|
||||
return Color.hsv(
|
||||
hue = (hue * 360).toFloat(),
|
||||
saturation = saturation.toFloat(),
|
||||
value = brightness.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly)
|
||||
*/
|
||||
fun splitSuffix(name: String): Pair<String, String> {
|
||||
if (name.length < 5) return Pair(name, "")
|
||||
|
||||
val suffix = name.takeLast(5)
|
||||
if (suffix.startsWith("#") && suffix.drop(1).all {
|
||||
it.isDigit() || it.lowercaseChar() in 'a'..'f'
|
||||
}) {
|
||||
val base = name.dropLast(5)
|
||||
return Pair(base, suffix)
|
||||
}
|
||||
|
||||
return Pair(name, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style content formatting with proper hashtag and mention handling
|
||||
*/
|
||||
private fun appendIOSFormattedContent(
|
||||
builder: AnnotatedString.Builder,
|
||||
content: String,
|
||||
mentions: List<String>?,
|
||||
currentUserNickname: String,
|
||||
colorScheme: ColorScheme
|
||||
baseColor: Color,
|
||||
isSelf: Boolean,
|
||||
isDark: Boolean
|
||||
) {
|
||||
val isMentioned = mentions?.contains(currentUserNickname) == true
|
||||
|
||||
// Parse hashtags and mentions
|
||||
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
|
||||
|
||||
val hashtagMatches = hashtagPattern.findAll(content).toList()
|
||||
val mentionMatches = mentionPattern.findAll(content).toList()
|
||||
|
||||
// Combine and sort all matches
|
||||
val allMatches = (hashtagMatches.map { it.range to "hashtag" } +
|
||||
mentionMatches.map { it.range to "mention" })
|
||||
.sortedBy { it.first.first }
|
||||
// Combine and sort matches, but exclude hashtags that overlap with mentions
|
||||
val mentionRanges = mentionMatches.map { it.range }
|
||||
fun overlapsMention(range: IntRange): Boolean {
|
||||
return mentionRanges.any { mentionRange ->
|
||||
range.first < mentionRange.last && range.last > mentionRange.first
|
||||
}
|
||||
}
|
||||
|
||||
val allMatches = mutableListOf<Pair<IntRange, String>>()
|
||||
|
||||
// Add hashtag matches that don't overlap with mentions
|
||||
for (match in hashtagMatches) {
|
||||
if (!overlapsMention(match.range)) {
|
||||
allMatches.add(match.range to "hashtag")
|
||||
}
|
||||
}
|
||||
|
||||
// Add all mention matches
|
||||
for (match in mentionMatches) {
|
||||
allMatches.add(match.range to "mention")
|
||||
}
|
||||
|
||||
allMatches.sortBy { it.first.first }
|
||||
|
||||
var lastEnd = 0
|
||||
val isMentioned = mentions?.contains(currentUserNickname) == true
|
||||
|
||||
for ((range, type) in allMatches) {
|
||||
// Add text before the match
|
||||
// Add text before match
|
||||
if (lastEnd < range.first) {
|
||||
val beforeText = content.substring(lastEnd, range.first)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
if (beforeText.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
// Make entire message bold if user is mentioned
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(beforeText)
|
||||
}
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// Add the styled match
|
||||
// Add styled match
|
||||
val matchText = content.substring(range.first, range.last + 1)
|
||||
when (type) {
|
||||
"hashtag" -> {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF0080FF), // Blue
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
|
||||
))
|
||||
}
|
||||
"mention" -> {
|
||||
// iOS-style mention with hashtag suffix support
|
||||
val mentionWithoutAt = matchText.removePrefix("@")
|
||||
val (mBase, mSuffix) = splitSuffix(mentionWithoutAt)
|
||||
|
||||
// Check if this mention targets current user
|
||||
val isMentionToMe = mBase == currentUserNickname
|
||||
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
|
||||
|
||||
// "@" symbol
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFFFF9500), // Orange
|
||||
color = mentionColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append("@")
|
||||
builder.pop()
|
||||
|
||||
// Base name
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append(mBase)
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix in lighter color
|
||||
if (mSuffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor.copy(alpha = 0.6f),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append(mSuffix)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
"hashtag" -> {
|
||||
// iOS-style: render hashtags like normal content (no special styling)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(matchText)
|
||||
}
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
|
||||
lastEnd = range.last + 1
|
||||
}
|
||||
@@ -155,11 +338,17 @@ private fun appendFormattedContent(
|
||||
if (lastEnd < content.length) {
|
||||
val remainingText = content.substring(lastEnd)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
builder.append(remainingText)
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(remainingText)
|
||||
}
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.nostr.NostrGeohashService
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.*
|
||||
@@ -46,7 +48,6 @@ class ChatViewModel(
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||
private val notificationManager = NotificationManager(application.applicationContext)
|
||||
|
||||
// Delegate handler for mesh callbacks
|
||||
private val meshDelegateHandler = MeshDelegateHandler(
|
||||
state = state,
|
||||
@@ -60,6 +61,16 @@ class ChatViewModel(
|
||||
getMeshService = { meshService }
|
||||
)
|
||||
|
||||
// Nostr and Geohash service - initialize singleton
|
||||
private val nostrGeohashService = NostrGeohashService.initialize(
|
||||
application = application,
|
||||
state = state,
|
||||
messageManager = messageManager,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = meshDelegateHandler,
|
||||
coroutineScope = viewModelScope
|
||||
)
|
||||
|
||||
// Expose state through LiveData (maintaining the same interface)
|
||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
||||
@@ -88,6 +99,11 @@ class ChatViewModel(
|
||||
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
|
||||
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
|
||||
val showAppInfo: LiveData<Boolean> = state.showAppInfo
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: LiveData<Boolean> = state.isTeleported
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||
|
||||
init {
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
@@ -125,6 +141,15 @@ class ChatViewModel(
|
||||
// Initialize session state monitoring
|
||||
initializeSessionStateMonitoring()
|
||||
|
||||
// Initialize location channel state
|
||||
nostrGeohashService.initializeLocationChannelState()
|
||||
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Initialize Nostr integration
|
||||
nostrGeohashService.initializeNostrIntegration()
|
||||
|
||||
// Note: Mesh service is now started by MainActivity
|
||||
|
||||
// Show welcome message if no peers after delay
|
||||
@@ -195,21 +220,15 @@ class ChatViewModel(
|
||||
|
||||
// Check for commands
|
||||
if (content.startsWith("/")) {
|
||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID) { messageContent, mentions, channel ->
|
||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
||||
meshService.sendMessage(messageContent, mentions, channel)
|
||||
}
|
||||
}, this)
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
||||
val channels = messageManager.parseChannels(content)
|
||||
|
||||
// Auto-join mentioned channels
|
||||
channels.forEach { channel ->
|
||||
if (!state.getJoinedChannelsValue().contains(channel)) {
|
||||
joinChannel(channel)
|
||||
}
|
||||
}
|
||||
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
|
||||
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
|
||||
|
||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
val currentChannelValue = state.getCurrentChannelValue()
|
||||
@@ -227,46 +246,55 @@ class ChatViewModel(
|
||||
meshService.sendPrivateMessage(messageContent, peerID, recipientNicknameParam, messageId)
|
||||
}
|
||||
} else {
|
||||
// Send public/channel message
|
||||
val message = BitchatMessage(
|
||||
sender = state.getNicknameValue() ?: meshService.myPeerID,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = meshService.myPeerID,
|
||||
mentions = if (mentions.isNotEmpty()) mentions else null,
|
||||
channel = currentChannelValue
|
||||
)
|
||||
|
||||
if (currentChannelValue != null) {
|
||||
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
|
||||
|
||||
// Check if encrypted channel
|
||||
if (channelManager.hasChannelKey(currentChannelValue)) {
|
||||
channelManager.sendEncryptedChannelMessage(
|
||||
content,
|
||||
mentions,
|
||||
currentChannelValue,
|
||||
state.getNicknameValue(),
|
||||
meshService.myPeerID,
|
||||
onEncryptedPayload = { encryptedData ->
|
||||
// This would need proper mesh service integration
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
},
|
||||
onFallback = {
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
}
|
||||
// Check if we're in a location channel
|
||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Send to geohash channel via Nostr ephemeral event
|
||||
nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
||||
} else {
|
||||
messageManager.addMessage(message)
|
||||
meshService.sendMessage(content, mentions, null)
|
||||
// Send public/channel message via mesh
|
||||
val message = BitchatMessage(
|
||||
sender = state.getNicknameValue() ?: meshService.myPeerID,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = meshService.myPeerID,
|
||||
mentions = if (mentions.isNotEmpty()) mentions else null,
|
||||
channel = currentChannelValue
|
||||
)
|
||||
|
||||
if (currentChannelValue != null) {
|
||||
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
|
||||
|
||||
// Check if encrypted channel
|
||||
if (channelManager.hasChannelKey(currentChannelValue)) {
|
||||
channelManager.sendEncryptedChannelMessage(
|
||||
content,
|
||||
mentions,
|
||||
currentChannelValue,
|
||||
state.getNicknameValue(),
|
||||
meshService.myPeerID,
|
||||
onEncryptedPayload = { encryptedData ->
|
||||
// This would need proper mesh service integration
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
},
|
||||
onFallback = {
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
meshService.sendMessage(content, mentions, currentChannelValue)
|
||||
}
|
||||
} else {
|
||||
messageManager.addMessage(message)
|
||||
meshService.sendMessage(content, mentions, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Utility Functions
|
||||
|
||||
fun getPeerIDForNickname(nickname: String): String? {
|
||||
@@ -323,6 +351,22 @@ class ChatViewModel(
|
||||
state.setPeerRSSI(rssiValues)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// MARK: - Debug and Troubleshooting
|
||||
|
||||
fun getDebugStatus(): String {
|
||||
@@ -360,7 +404,7 @@ class ChatViewModel(
|
||||
// MARK: - Mention Autocomplete
|
||||
|
||||
fun updateMentionSuggestions(input: String) {
|
||||
commandProcessor.updateMentionSuggestions(input, meshService)
|
||||
commandProcessor.updateMentionSuggestions(input, meshService, this)
|
||||
}
|
||||
|
||||
fun selectMentionSuggestion(nickname: String, currentText: String): String {
|
||||
@@ -423,6 +467,9 @@ class ChatViewModel(
|
||||
// Clear all notifications
|
||||
notificationManager.clearAllNotifications()
|
||||
|
||||
// Clear geohash message history
|
||||
nostrGeohashService.clearGeohashMessageHistory()
|
||||
|
||||
// Reset nickname
|
||||
val newNickname = "anon${Random.nextInt(1000, 9999)}"
|
||||
state.setNickname(newNickname)
|
||||
@@ -471,6 +518,79 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get participant count for a specific geohash (5-minute activity window)
|
||||
*/
|
||||
fun geohashParticipantCount(geohash: String): Int {
|
||||
return nostrGeohashService.geohashParticipantCount(geohash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin sampling multiple geohashes for participant activity
|
||||
*/
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
nostrGeohashService.beginGeohashSampling(geohashes)
|
||||
}
|
||||
|
||||
/**
|
||||
* End geohash sampling
|
||||
*/
|
||||
fun endGeohashSampling() {
|
||||
nostrGeohashService.endGeohashSampling()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if a geohash person is teleported (iOS-compatible)
|
||||
*/
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||
return nostrGeohashService.isPersonTeleported(pubkeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start geohash DM with pubkey hex (iOS-compatible)
|
||||
*/
|
||||
fun startGeohashDM(pubkeyHex: String) {
|
||||
nostrGeohashService.startGeohashDM(pubkeyHex) { convKey ->
|
||||
startPrivateChat(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||
nostrGeohashService.selectLocationChannel(channel)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// MARK: - Navigation Management
|
||||
|
||||
fun showAppInfo() {
|
||||
@@ -525,4 +645,24 @@ class ChatViewModel(
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS-Compatible Color System
|
||||
|
||||
/**
|
||||
* Get consistent color for a mesh peer by ID (iOS-compatible)
|
||||
*/
|
||||
fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
// Try to get stable Noise key, fallback to peer ID
|
||||
val seed = "noise:${peerID.lowercase()}"
|
||||
return colorForPeerSeed(seed, isDark).copy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Command Processing
|
||||
|
||||
fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit): Boolean {
|
||||
fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
|
||||
if (!command.startsWith("/")) return false
|
||||
|
||||
val parts = command.split(" ")
|
||||
@@ -38,7 +38,7 @@ class CommandProcessor(
|
||||
when (cmd) {
|
||||
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
||||
"/m", "/msg" -> handleMessageCommand(parts, meshService)
|
||||
"/w" -> handleWhoCommand(meshService)
|
||||
"/w" -> handleWhoCommand(meshService, viewModel)
|
||||
"/clear" -> handleClearCommand()
|
||||
"/pass" -> handlePassCommand(parts, myPeerID)
|
||||
"/block" -> handleBlockCommand(parts, meshService)
|
||||
@@ -130,19 +130,53 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWhoCommand(meshService: BluetoothMeshService) {
|
||||
val connectedPeers = state.getConnectedPeersValue()
|
||||
val peerList = connectedPeers.joinToString(", ") { peerID ->
|
||||
// Convert peerID to nickname using the mesh service
|
||||
getPeerNickname(peerID, meshService)
|
||||
private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
|
||||
// Channel-aware who command (matches iOS behavior)
|
||||
val (peerList, contextDescription) = if (viewModel != null) {
|
||||
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: show Bluetooth-connected peers
|
||||
val connectedPeers = state.getConnectedPeersValue()
|
||||
val peerList = connectedPeers.joinToString(", ") { peerID ->
|
||||
getPeerNickname(peerID, meshService)
|
||||
}
|
||||
Pair(peerList, "online users")
|
||||
}
|
||||
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Location channel: show geohash participants
|
||||
val geohashPeople = viewModel.geohashPeople.value ?: emptyList()
|
||||
val currentNickname = state.getNicknameValue()
|
||||
|
||||
val participantList = geohashPeople.mapNotNull { person ->
|
||||
val displayName = person.displayName
|
||||
// Exclude self from list
|
||||
if (displayName.startsWith("${currentNickname}#")) {
|
||||
null
|
||||
} else {
|
||||
displayName
|
||||
}
|
||||
}.joinToString(", ")
|
||||
|
||||
Pair(participantList, "participants in ${selectedChannel.channel.geohash}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to mesh behavior
|
||||
val connectedPeers = state.getConnectedPeersValue()
|
||||
val peerList = connectedPeers.joinToString(", ") { peerID ->
|
||||
getPeerNickname(peerID, meshService)
|
||||
}
|
||||
Pair(peerList, "online users")
|
||||
}
|
||||
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = if (connectedPeers.isEmpty()) {
|
||||
"no one else is online right now."
|
||||
content = if (peerList.isEmpty()) {
|
||||
"no one else is around right now."
|
||||
} else {
|
||||
"online users: $peerList"
|
||||
"$contextDescription: $peerList"
|
||||
},
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
@@ -383,7 +417,7 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Mention Autocomplete
|
||||
|
||||
fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService) {
|
||||
fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
|
||||
// Check if input contains @ and we're at the end of a word or at the end of input
|
||||
val atIndex = input.lastIndexOf('@')
|
||||
if (atIndex == -1) {
|
||||
@@ -402,11 +436,38 @@ class CommandProcessor(
|
||||
return
|
||||
}
|
||||
|
||||
// Get all connected peer nicknames - now using direct access instead of reflection
|
||||
val peerNicknames = meshService.getPeerNicknames().values.toList()
|
||||
// Get peer candidates based on active channel (matches iOS logic exactly)
|
||||
val peerCandidates: List<String> = if (viewModel != null) {
|
||||
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: use Bluetooth mesh peer nicknames
|
||||
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
|
||||
}
|
||||
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Location channel: use geohash participants with collision-resistant suffixes
|
||||
val geohashPeople = viewModel.geohashPeople.value ?: emptyList()
|
||||
val currentNickname = state.getNicknameValue()
|
||||
|
||||
geohashPeople.mapNotNull { person ->
|
||||
val displayName = person.displayName
|
||||
// Exclude self from suggestions
|
||||
if (displayName.startsWith("${currentNickname}#")) {
|
||||
null
|
||||
} else {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to mesh peers if no viewModel available
|
||||
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
|
||||
}
|
||||
|
||||
// Filter nicknames based on the text after @
|
||||
val filteredNicknames = peerNicknames.filter { nickname ->
|
||||
val filteredNicknames = peerCandidates.filter { nickname ->
|
||||
nickname.startsWith(textAfterAt, ignoreCase = true)
|
||||
}.sorted()
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* GeohashPeopleList - iOS-compatible component for displaying geohash participants
|
||||
* Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers
|
||||
*/
|
||||
|
||||
/**
|
||||
* GeoPerson data class - matches iOS GeoPerson structure exactly
|
||||
*/
|
||||
data class GeoPerson(
|
||||
val id: String, // pubkey hex (lowercased) - matches iOS
|
||||
val displayName: String, // nickname with #suffix - matches iOS
|
||||
val lastSeen: Date // activity timestamp - matches iOS
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GeohashPeopleList(
|
||||
viewModel: ChatViewModel,
|
||||
onTapPerson: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe geohash people from ChatViewModel
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val isTeleported by viewModel.isTeleported.observeAsState(false)
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
|
||||
Column {
|
||||
// Header matching iOS style
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "PEOPLE",
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (geohashPeople.isEmpty()) {
|
||||
// Empty state - matches iOS "nobody around..."
|
||||
Text(
|
||||
text = "nobody around...",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
// Get current geohash identity for "me" detection
|
||||
val myHex = remember(selectedLocationChannel) {
|
||||
when (val channel = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
try {
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = channel.channel.geohash,
|
||||
context = viewModel.getApplication()
|
||||
)
|
||||
identity.publicKeyHex.lowercase()
|
||||
} catch (e: Exception) {
|
||||
Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Sort people: me first, then by lastSeen (matches iOS exactly)
|
||||
val orderedPeople = remember(geohashPeople, myHex) {
|
||||
geohashPeople.sortedWith { a, b ->
|
||||
when {
|
||||
myHex != null && a.id == myHex && b.id != myHex -> -1
|
||||
myHex != null && b.id == myHex && a.id != myHex -> 1
|
||||
else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val firstID = orderedPeople.firstOrNull()?.id
|
||||
|
||||
orderedPeople.forEach { person ->
|
||||
GeohashPersonItem(
|
||||
person = person,
|
||||
isFirst = person.id == firstID,
|
||||
isMe = myHex != null && person.id == myHex,
|
||||
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
|
||||
isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id),
|
||||
isMyTeleported = person.id == myHex && isTeleported,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onTap = {
|
||||
if (person.id != myHex) {
|
||||
// TODO: Re-enable when NIP-17 geohash DM issues are fixed
|
||||
// Start geohash DM (iOS-compatible)
|
||||
// viewModel.startGeohashDM(person.id)
|
||||
onTapPerson()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GeohashPersonItem(
|
||||
person: GeoPerson,
|
||||
isFirst: Boolean,
|
||||
isMe: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
isTeleported: Boolean,
|
||||
isMyTeleported: Boolean,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTap() }
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.padding(top = if (isFirst) 10.dp else 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon logic matching iOS exactly
|
||||
if (hasUnreadDM) {
|
||||
// Unread DM indicator (orange envelope)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = "Unread message",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else {
|
||||
// Face icon with teleportation state
|
||||
val (iconName, iconColor) = when {
|
||||
isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me
|
||||
isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others
|
||||
isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me
|
||||
else -> "face.smiling" to colorScheme.onSurface // Regular color for others
|
||||
}
|
||||
|
||||
// Use appropriate Material icon (closest match to iOS SF Symbols)
|
||||
val icon = when (iconName) {
|
||||
"face.dashed" -> Icons.Default.Face // Use regular face icon (no dashed variant in Material)
|
||||
else -> Icons.Default.Face
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Display name with suffix handling (matches iOS splitSuffix logic)
|
||||
val (baseName, suffix) = com.bitchat.android.ui.splitSuffix(person.displayName)
|
||||
|
||||
// Get consistent peer color (matches iOS color assignment exactly)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
color = baseColor
|
||||
)
|
||||
|
||||
// Suffix (collision-resistant #abcd) in lighter shade
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
// "You" indicator for current user
|
||||
if (isMe) {
|
||||
Text(
|
||||
text = " (you)",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = baseColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.GeohashChannel
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
* Direct port from iOS LocationChannelsSheet for 100% compatibility
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LocationChannelsSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: ChatViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locationManager = LocationChannelManager.getInstance(context)
|
||||
|
||||
// Observe location manager state
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val selectedChannel by locationManager.selectedChannel.observeAsState()
|
||||
val teleported by locationManager.teleported.observeAsState(false)
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
|
||||
// CRITICAL FIX: Observe reactive participant counts for real-time updates
|
||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
|
||||
|
||||
// UI state
|
||||
var customGeohash by remember { mutableStateOf("") }
|
||||
var customError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// iOS system colors (matches iOS exactly)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "#location channels",
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
// Permission handling
|
||||
when (permissionState) {
|
||||
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
|
||||
Button(
|
||||
onClick = { locationManager.enableLocationChannels() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "get location and my geohashes",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LocationChannelManager.PermissionState.DENIED,
|
||||
LocationChannelManager.PermissionState.RESTRICTED -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "location permission denied. enable in settings to use location channels.",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "open settings",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocationChannelManager.PermissionState.AUTHORIZED -> {
|
||||
// Authorized - show channels below
|
||||
}
|
||||
|
||||
null -> {
|
||||
// Loading state
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// Channel list (iOS-style plain list)
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// Mesh option first
|
||||
item {
|
||||
ChannelRow(
|
||||
title = meshTitleWithCount(viewModel),
|
||||
subtitle = "#bluetooth • ${bluetoothRangeString()}",
|
||||
isSelected = selectedChannel is ChannelID.Mesh,
|
||||
titleColor = standardBlue,
|
||||
titleBold = meshCount(viewModel) > 0,
|
||||
onClick = {
|
||||
locationManager.select(ChannelID.Mesh)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Nearby options
|
||||
if (availableChannels.isNotEmpty()) {
|
||||
items(availableChannels) { channel ->
|
||||
val coverage = coverageString(channel.geohash.length)
|
||||
val nameBase = locationNames[channel.level]
|
||||
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
||||
val subtitlePrefix = "#${channel.geohash} • $coverage"
|
||||
// CRITICAL FIX: Use reactive participant count from LiveData
|
||||
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
|
||||
val highlight = participantCount > 0
|
||||
|
||||
ChannelRow(
|
||||
title = geohashTitleWithCount(channel, participantCount),
|
||||
subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""),
|
||||
isSelected = isChannelSelected(channel, selectedChannel),
|
||||
titleColor = standardGreen,
|
||||
titleBold = highlight,
|
||||
onClick = {
|
||||
// Selecting a suggested nearby channel is not a teleport
|
||||
locationManager.setTeleported(false)
|
||||
locationManager.select(ChannelID.Location(channel))
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = "finding nearby channels…",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom geohash teleport (iOS-style inline form)
|
||||
item {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
value = customGeohash,
|
||||
onValueChange = { newValue ->
|
||||
// iOS-style geohash validation (base32 characters only)
|
||||
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||
val filtered = newValue
|
||||
.lowercase()
|
||||
.replace("#", "")
|
||||
.filter { it in allowed }
|
||||
.take(12)
|
||||
|
||||
customGeohash = filtered
|
||||
customError = null
|
||||
},
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
decorationBox = { innerTextField ->
|
||||
if (customGeohash.isEmpty()) {
|
||||
Text(
|
||||
text = "geohash",
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
|
||||
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
||||
val isValid = validateGeohash(normalized)
|
||||
|
||||
// iOS-style teleport button
|
||||
Button(
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
val level = levelForLength(normalized.length)
|
||||
val channel = GeohashChannel(level = level, geohash = normalized)
|
||||
// Mark this selection as a manual teleport
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(ChannelID.Location(channel))
|
||||
onDismiss()
|
||||
} else {
|
||||
customError = "invalid geohash"
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "teleport",
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
// iOS has a face.dashed icon, use closest Material equivalent
|
||||
Text(
|
||||
text = "📍",
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customError?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer action - remove location access
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Red.copy(alpha = 0.08f),
|
||||
contentColor = Color(0xFFBF1A1A)
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "remove location access",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle management
|
||||
LaunchedEffect(isPresented) {
|
||||
if (isPresented) {
|
||||
// Refresh channels when opening
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
// Begin periodic refresh while sheet is open
|
||||
locationManager.beginLiveRefresh()
|
||||
|
||||
// Begin multi-channel sampling for counts
|
||||
val geohashes = availableChannels.map { it.geohash }
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
} else {
|
||||
locationManager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
|
||||
// React to permission changes
|
||||
LaunchedEffect(permissionState) {
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
// React to available channels changes
|
||||
LaunchedEffect(availableChannels) {
|
||||
val geohashes = availableChannels.map { it.geohash }
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
isSelected: Boolean,
|
||||
titleColor: Color? = null,
|
||||
titleBold: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
// iOS-style list row (plain button, no card background)
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// Split title to handle count part with smaller font (iOS style)
|
||||
val (baseTitle, countSuffix) = splitTitleAndCount(title)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = baseTitle,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal,
|
||||
color = titleColor ?: MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
countSuffix?.let { count ->
|
||||
Text(
|
||||
text = count,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
Text(
|
||||
text = "✔︎",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFF32D74B) // iOS green for checkmark
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions (matching iOS implementation)
|
||||
|
||||
private fun splitTitleAndCount(title: String): Pair<String, String?> {
|
||||
val lastBracketIndex = title.lastIndexOf('[')
|
||||
return if (lastBracketIndex != -1) {
|
||||
val prefix = title.substring(0, lastBracketIndex).trim()
|
||||
val suffix = title.substring(lastBracketIndex)
|
||||
Pair(prefix, suffix)
|
||||
} else {
|
||||
Pair(title, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meshTitleWithCount(viewModel: ChatViewModel): String {
|
||||
val meshCount = meshCount(viewModel)
|
||||
val noun = if (meshCount == 1) "person" else "people"
|
||||
return "mesh [$meshCount $noun]"
|
||||
}
|
||||
|
||||
private fun meshCount(viewModel: ChatViewModel): Int {
|
||||
val myID = viewModel.meshService.myPeerID
|
||||
return viewModel.connectedPeers.value?.count { peerID ->
|
||||
peerID != myID
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
|
||||
val noun = if (participantCount == 1) "person" else "people"
|
||||
return "${channel.level.displayName.lowercase()} [$participantCount $noun]"
|
||||
}
|
||||
|
||||
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
|
||||
return when (selectedChannel) {
|
||||
is ChannelID.Location -> selectedChannel.channel == channel
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateGeohash(geohash: String): Boolean {
|
||||
if (geohash.isEmpty() || geohash.length > 12) return false
|
||||
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||
return geohash.all { it in allowed }
|
||||
}
|
||||
|
||||
private fun levelForLength(length: Int): GeohashChannelLevel {
|
||||
return when (length) {
|
||||
in 0..2 -> GeohashChannelLevel.COUNTRY
|
||||
in 3..4 -> GeohashChannelLevel.REGION
|
||||
5 -> GeohashChannelLevel.CITY
|
||||
6 -> GeohashChannelLevel.NEIGHBORHOOD
|
||||
7 -> GeohashChannelLevel.BLOCK
|
||||
else -> GeohashChannelLevel.BLOCK
|
||||
}
|
||||
}
|
||||
|
||||
private fun coverageString(precision: Int): String {
|
||||
// Approximate max cell dimension at equator for a given geohash length
|
||||
val maxMeters = when (precision) {
|
||||
2 -> 1_250_000.0
|
||||
3 -> 156_000.0
|
||||
4 -> 39_100.0
|
||||
5 -> 4_890.0
|
||||
6 -> 1_220.0
|
||||
7 -> 153.0
|
||||
8 -> 38.2
|
||||
9 -> 4.77
|
||||
10 -> 1.19
|
||||
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
|
||||
}
|
||||
|
||||
// Use metric system for simplicity (could be made locale-aware)
|
||||
val km = maxMeters / 1000.0
|
||||
return "~${formatDistance(km)} km"
|
||||
}
|
||||
|
||||
private fun formatDistance(value: Double): String {
|
||||
return when {
|
||||
value >= 100 -> String.format("%.0f", value)
|
||||
value >= 10 -> String.format("%.1f", value)
|
||||
else -> String.format("%.1f", value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bluetoothRangeString(): String {
|
||||
// Approximate Bluetooth LE range for typical mobile devices
|
||||
return "~10–50 m"
|
||||
}
|
||||
|
||||
private fun formattedNamePrefix(level: GeohashChannelLevel): String {
|
||||
return when (level) {
|
||||
GeohashChannelLevel.COUNTRY -> ""
|
||||
else -> "~"
|
||||
}
|
||||
}
|
||||
@@ -106,21 +106,35 @@ fun SidebarOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// People section
|
||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
||||
item {
|
||||
PeopleSection(
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.startPrivateChat(peerID)
|
||||
onDismiss()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
|
||||
when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Show geohash people list when in location channel
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = onDismiss
|
||||
)
|
||||
}
|
||||
)
|
||||
else -> {
|
||||
// Show mesh peer list when in mesh channel (default)
|
||||
PeopleSection(
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
viewModel = viewModel,
|
||||
onPrivateChatStart = { peerID ->
|
||||
viewModel.startPrivateChat(peerID)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +321,7 @@ fun PeopleSection(
|
||||
isFavorite = isFavorite,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(peerID) },
|
||||
onToggleFavorite = {
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||
@@ -331,10 +346,20 @@ private fun PeerItem(
|
||||
isFavorite: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
onItemClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
unreadCount: Int = 0
|
||||
) {
|
||||
// Split display name for hashtag suffix support (iOS-compatible)
|
||||
val (baseName, suffix) = com.bitchat.android.ui.splitSuffix(displayName)
|
||||
val isMe = displayName == "You" || peerID == viewModel.nickname.value
|
||||
|
||||
// Get consistent peer color (iOS-compatible)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -346,11 +371,14 @@ private fun PeerItem(
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Show unread badge or signal strength
|
||||
// Show unread badge or signal strength
|
||||
if (hasUnreadDM) {
|
||||
UnreadBadge(
|
||||
count = unreadCount,
|
||||
colorScheme = colorScheme
|
||||
// Show mail icon for unread DMs (iOS orange)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = "Unread message",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else {
|
||||
// Signal strength indicators
|
||||
@@ -362,13 +390,34 @@ private fun PeerItem(
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// Display name with iOS-style color and hashtag suffix support
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
color = baseColor
|
||||
)
|
||||
|
||||
// Hashtag suffix in lighter shade (iOS-style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Favorite star with proper filled/outlined states
|
||||
IconButton(
|
||||
@@ -379,12 +428,14 @@ private fun PeerItem(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700)
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SignalStrengthIndicator(
|
||||
signalStrength: Int,
|
||||
|
||||
Reference in New Issue
Block a user