This commit is contained in:
callebtc 2026-07-27 03:50:46 +02:00
parent 521784b359
commit 3dbe9c1c52
4 changed files with 423 additions and 397 deletions

View File

@ -68,6 +68,15 @@ internal val AboutHorizontalPadding = 20.dp
/** Card corner radius for grouped rows. */
internal val AboutCardShape = RoundedCornerShape(16.dp)
/** Leading icon column in settings-style sheet rows. */
internal val SheetRowLeadingSlot = 22.dp
internal val SheetRowLeadingGutter = 16.dp
internal val SheetRowHorizontal = 16.dp
internal val SheetRowVertical = 13.dp
internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter
/** Selection indicator sized for [SheetRowLeadingSlot]. */
internal val SheetRowSelectedDot = 12.dp
/**
* Two top-level views of the sheet: what the app is and how to drive it, versus the knobs.
*/
@ -99,6 +108,66 @@ internal fun AboutSectionLabel(
)
}
/**
* Icon + title on one line, optional short subtitle beneath. Used by location / network sheets.
*/
@Composable
internal fun SheetIconSectionHeader(
icon: ImageVector,
title: String,
subtitle: String? = null,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Text(
text = title,
fontSize = 17.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
color = colorScheme.primary
)
}
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
fontSize = 12.sp,
lineHeight = 17.sp,
fontFamily = FontFamily.Monospace,
color = palette.textSecondary
)
}
}
}
/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */
@Composable
internal fun SheetCardDivider() {
val palette = LocalBitchatPalette.current
HorizontalDivider(
modifier = Modifier.padding(start = SheetRowDividerInset),
thickness = 1.dp,
color = palette.outlineVariant
)
}
/**
* Centered app identity block: logo, wordmark, tagline, version.
*/

View File

@ -1,13 +1,16 @@
package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.Public
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -17,25 +20,20 @@ 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.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.util.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.util.*
/**
* GeohashPeopleList - iOS-compatible component for displaying geohash participants
* Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers
* Geohash people list card groups matching location / settings sheet rows.
*/
/**
* 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 displayName: String, // nickname with #suffix - matches iOS
val lastSeen: Date // activity timestamp - matches iOS
)
@ -46,32 +44,41 @@ fun GeohashPeopleList(
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
// Observe geohash people from ChatViewModel
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val palette = LocalBitchatPalette.current
Column(modifier = modifier) {
if (geohashPeople.isEmpty()) {
SheetSectionLabel(text = stringResource(R.string.section_on_location))
// Empty state - matches iOS "nobody around..."
Text(
text = stringResource(R.string.nobody_around),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = palette.textTertiary,
modifier = Modifier.padding(
horizontal = SheetHorizontalPadding + 14.dp,
vertical = 12.dp
)
SheetIconSectionHeader(
icon = Icons.Outlined.LocationOn,
title = stringResource(R.string.section_on_location)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = palette.surface,
shape = AboutCardShape
) {
Text(
text = stringResource(R.string.nobody_around),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = palette.textTertiary,
modifier = Modifier.padding(
horizontal = SheetRowHorizontal,
vertical = SheetRowVertical
)
)
}
} else {
// Get current geohash identity for "me" detection
val myHex = remember(selectedLocationChannel) {
when (val channel = selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> {
@ -89,30 +96,26 @@ fun GeohashPeopleList(
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
else -> b.lastSeen.compareTo(a.lastSeen)
}
}
}
// Compute base name collisions to decide whether to show hash suffix
val baseNameCounts = remember(geohashPeople) {
val counts = mutableMapOf<String, Int>()
geohashPeople.forEach { person ->
val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName)
val (b, _) = splitSuffix(person.displayName)
counts[b] = (counts[b] ?: 0) + 1
}
counts
}
// Split by presence: people physically in the geohash versus people who teleported
// in. Mixing them hid the fact that a "nearby" channel can contain remote users.
fun personTeleported(person: GeoPerson): Boolean = if (person.id == myHex) {
isTeleported
} else {
@ -132,11 +135,9 @@ fun GeohashPeopleList(
nickname = nickname,
colorScheme = colorScheme,
viewModel = viewModel,
showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1,
showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1,
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()
}
@ -145,13 +146,48 @@ fun GeohashPeopleList(
}
if (localPeople.isNotEmpty()) {
SheetSectionLabel(text = stringResource(R.string.section_on_location))
localPeople.forEach { personRow(it) }
SheetIconSectionHeader(
icon = Icons.Outlined.LocationOn,
title = stringResource(R.string.section_on_location)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = palette.surface,
shape = AboutCardShape
) {
Column {
localPeople.forEachIndexed { index, person ->
if (index > 0) SheetCardDivider()
personRow(person)
}
}
}
}
if (teleportedPeople.isNotEmpty()) {
SheetSectionLabel(text = stringResource(R.string.section_teleported_in))
teleportedPeople.forEach { personRow(it) }
SheetIconSectionHeader(
icon = Icons.Outlined.Explore,
title = stringResource(R.string.section_teleported_in),
modifier = Modifier.padding(top = if (localPeople.isNotEmpty()) 20.dp else 0.dp)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = palette.surface,
shape = AboutCardShape
) {
Column {
teleportedPeople.forEachIndexed { index, person ->
if (index > 0) SheetCardDivider()
personRow(person)
}
}
}
}
}
}
@ -172,69 +208,57 @@ private fun GeohashPersonItem(
) {
val palette = LocalBitchatPalette.current
Surface(
onClick = onTap,
color = palette.surface,
shape = RoundedCornerShape(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
) {
val (iconName, iconColor) = when {
isMe && isMyTeleported -> "face.dashed" to palette.accentOrange
isTeleported -> "face.dashed" to palette.textSecondary
isMe -> "face.smiling" to palette.accentOrange
else -> "face.smiling" to palette.textSecondary
}
val statusIcon = when (iconName) {
"face.dashed" -> Icons.Outlined.Explore
else -> Icons.Outlined.LocationOn
}
val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName)
val baseName = truncateNickname(baseNameRaw)
val suffix = if (showHashSuffix) suffixRaw else ""
val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark)
val baseColor = if (isMe) palette.accentOrange else assignedColor
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp)
.padding(horizontal = 14.dp),
.clickable(onClick = onTap)
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
verticalAlignment = Alignment.CenterVertically
) {
// Icon logic matching iOS exactly
if (hasUnreadDM) {
// Unread DM indicator (orange envelope)
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(18.dp),
tint = palette.accentOrange
)
} else {
// Face icon with teleportation state
val (iconName, iconColor) = when {
isMe && isMyTeleported -> "face.dashed" to palette.accentOrange
isTeleported -> "face.dashed" to palette.textSecondary
isMe -> "face.smiling" to palette.accentOrange
else -> "face.smiling" to palette.textSecondary
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
if (hasUnreadDM) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(22.dp),
tint = palette.accentOrange
)
} else {
Icon(
imageVector = statusIcon,
contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
modifier = Modifier.size(22.dp),
tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f)
)
}
// Teleported users get the pin (they chose this location); locals get the person.
val icon = when (iconName) {
"face.dashed" -> Icons.Outlined.Explore
else -> Icons.Outlined.LocationOn
}
Icon(
imageVector = icon,
contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
modifier = Modifier.size(18.dp),
tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Display name with suffix handling
val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(person.displayName)
val baseName = truncateNickname(baseNameRaw)
val suffix = if (showHashSuffix) suffixRaw else ""
// Get consistent peer color (matches iOS color assignment exactly)
val assignedColor = viewModel.colorForNostrPubkey(person.id, palette.isDark)
val baseColor = if (isMe) palette.accentOrange else assignedColor
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
// Base name with peer-specific color
Text(
text = baseName,
fontFamily = FontFamily.Monospace,
@ -245,7 +269,6 @@ private fun GeohashPersonItem(
overflow = TextOverflow.Ellipsis
)
// Suffix (collision-resistant #abcd) in lighter shade
if (suffix.isNotEmpty()) {
Text(
text = suffix,
@ -256,7 +279,6 @@ private fun GeohashPersonItem(
)
}
// "You" indicator for current user
if (isMe) {
Text(
text = stringResource(R.string.you_suffix),
@ -267,7 +289,4 @@ private fun GeohashPersonItem(
}
}
}
}
}

View File

@ -60,13 +60,13 @@ import kotlinx.coroutines.launch
* Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text.
* Selection dots and row icons sit in this column so every option lines up with About settings.
*/
private val ChannelLeadingSlot = 22.dp
private val ChannelLeadingGutter = 16.dp
private val ChannelRowHorizontal = 16.dp
private val ChannelRowVertical = 13.dp
private val ChannelDividerInset = ChannelRowHorizontal + ChannelLeadingSlot + ChannelLeadingGutter
private val ChannelLeadingSlot = SheetRowLeadingSlot
private val ChannelLeadingGutter = SheetRowLeadingGutter
private val ChannelRowHorizontal = SheetRowHorizontal
private val ChannelRowVertical = SheetRowVertical
private val ChannelDividerInset = SheetRowDividerInset
/** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */
private val ChannelSelectedDot = 12.dp
private val ChannelSelectedDot = SheetRowSelectedDot
/**
* Location Channels sheet: grouped card rows matching About Settings.
@ -187,7 +187,7 @@ fun LocationChannelsSheet(
// Mesh section: icon + title header, offline subtitle, then selection card
item(key = "mesh_card") {
Column {
ChannelSectionHeader(
SheetIconSectionHeader(
icon = Icons.Filled.Hub,
title = stringResource(R.string.mesh_title),
subtitle = stringResource(R.string.mesh_section_subtitle),
@ -223,7 +223,7 @@ fun LocationChannelsSheet(
// Location channels: globe + title, geohash subtitle, nearby levels + teleport
item(key = "channels_card") {
Column {
ChannelSectionHeader(
SheetIconSectionHeader(
icon = Icons.Outlined.Public,
title = stringResource(R.string.location_channels_heading),
subtitle = stringResource(R.string.location_channels_desc),
@ -241,7 +241,7 @@ fun LocationChannelsSheet(
if (locationServicesEnabled) {
if (nearbyChannels.isNotEmpty()) {
nearbyChannels.forEachIndexed { index, channel ->
if (index > 0) ChannelCardDivider()
if (index > 0) SheetCardDivider()
val coverage = coverageString(channel.geohash.length)
val nameBase = locationNames[channel.level]
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
@ -269,10 +269,10 @@ fun LocationChannelsSheet(
}
)
}
ChannelCardDivider()
SheetCardDivider()
} else if (showNearbyLoading) {
ChannelLoadingRow()
ChannelCardDivider()
SheetCardDivider()
}
}
@ -352,7 +352,7 @@ fun LocationChannelsSheet(
) {
Column {
bookmarks.forEachIndexed { index, gh ->
if (index > 0) ChannelCardDivider()
if (index > 0) SheetCardDivider()
val level = levelForLength(gh.length)
val channel = GeohashChannel(level = level, geohash = gh)
val coverage = coverageString(gh.length)
@ -498,63 +498,6 @@ fun LocationChannelsSheet(
}
}
@Composable
private fun ChannelCardDivider() {
val palette = LocalBitchatPalette.current
HorizontalDivider(
modifier = Modifier.padding(start = ChannelDividerInset),
thickness = 1.dp,
color = palette.outlineVariant
)
}
/**
* Section title row: icon + name on one line, optional short subtitle beneath.
*/
@Composable
private fun ChannelSectionHeader(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Text(
text = title,
fontSize = 17.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
color = colorScheme.primary
)
}
Text(
text = subtitle,
fontSize = 12.sp,
lineHeight = 17.sp,
fontFamily = FontFamily.Monospace,
color = palette.textSecondary
)
}
}
/**
* Single channel option settings-row geometry: 22.dp leading slot, title + subtitle, trailing.
* Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint).

View File

@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -109,89 +110,83 @@ fun MeshPeerListSheet(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp)
) {
// Header: badge + live participant count
item(key = "people_header") {
val peopleCount = when (selectedLocationChannel) {
is ChannelID.Location -> geohashPeopleCount
else -> connectedPeers.count { it != viewModel.myPeerID }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding + 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
SheetHeaderBadge(icon = Icons.Filled.Person)
AnimatedCountLabel(
count = peopleCount,
text = stringResource(R.string.people_count_title, peopleCount),
fontSize = 20.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = colorScheme.primary
)
}
val peopleCount = when (selectedLocationChannel) {
is ChannelID.Location -> geohashPeopleCount
else -> connectedPeers.count { it != viewModel.myPeerID }
}
// Channels section
if (joinedChannels.isNotEmpty()) {
item(key = "channels_header") {
SheetSectionLabel(text = stringResource(R.string.channels))
}
items(
items = joinedChannels.toList(),
key = { "channel_$it" }
) { channel ->
val isSelected = channel == currentChannel
val unreadCount = unreadChannelMessages[channel] ?: 0
ChannelRow(
channel = channel,
isSelected = isSelected,
unreadCount = unreadCount,
colorScheme = colorScheme,
onChannelClick = {
// Check if this is a DM channel (starts with @)
if (channel.startsWith("@")) {
// Extract peer name and find the peer ID
val peerName = channel.removePrefix("@")
val peerID =
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
onDismiss()
item(key = "channels_section") {
Column {
SheetIconSectionHeader(
icon = Icons.Filled.Tag,
title = stringResource(R.string.channels),
modifier = Modifier.padding(top = 8.dp)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = LocalBitchatPalette.current.surface,
shape = AboutCardShape
) {
Column {
joinedChannels.toList().forEachIndexed { index, channel ->
if (index > 0) SheetCardDivider()
val isSelected = channel == currentChannel
val unreadCount = unreadChannelMessages[channel] ?: 0
ChannelRow(
channel = channel,
isSelected = isSelected,
unreadCount = unreadCount,
colorScheme = colorScheme,
onChannelClick = {
if (channel.startsWith("@")) {
val peerName = channel.removePrefix("@")
val peerID =
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
onDismiss()
}
} else {
viewModel.switchToChannel(channel)
onDismiss()
}
},
onLeaveChannel = {
viewModel.leaveChannel(channel)
},
)
}
} else {
// Regular channel switch
viewModel.switchToChannel(channel)
onDismiss()
}
},
onLeaveChannel = {
viewModel.leaveChannel(channel)
},
)
}
}
}
}
// People section - switch between mesh and geohash lists (iOS-compatible)
// People / geohash participants
item(key = "people_section") {
when (selectedLocationChannel) {
is ChannelID.Location -> {
// Show geohash people list when in location channel
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = onDismiss
onTapPerson = onDismiss,
modifier = Modifier.padding(
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
)
)
}
else -> {
// Show mesh peer list when in mesh channel (default)
PeopleSection(
modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp),
modifier = Modifier.padding(
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
),
connectedPeers = connectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
@ -199,6 +194,7 @@ fun MeshPeerListSheet(
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
peopleCount = peopleCount,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
@ -239,8 +235,8 @@ fun MeshPeerListSheet(
}
}
/** Icon size for rows inside the sheets: one step down from the top bar. */
private val PeerRowIconSize = 18.dp
/** Icon size for trailing actions on peer rows (matches settings glyph scale). */
private val PeerRowIconSize = 22.dp
@Composable
private fun ChannelRow(
@ -253,54 +249,50 @@ private fun ChannelRow(
) {
val palette = LocalBitchatPalette.current
Surface(
onClick = onChannelClick,
color = if (isSelected) palette.surfaceVariant else palette.surface,
shape = RoundedCornerShape(10.dp),
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
.clickable(onClick = onChannelClick)
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp)
.padding(start = 14.dp, end = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Unread badge
if (unreadCount > 0) {
UnreadBadge(
count = unreadCount,
colorScheme = colorScheme
)
}
if (isSelected) {
Box(
modifier = Modifier
.size(SheetRowSelectedDot)
.background(palette.accentGreen, CircleShape)
)
} else if (unreadCount > 0) {
UnreadBadge(count = unreadCount, colorScheme = colorScheme)
} else {
Text(
text = channel,
text = "#",
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = if (isSelected) colorScheme.primary else palette.textPrimary,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Leave channel button
CloseButton(
onClick = onLeaveChannel,
fontWeight = FontWeight.Medium,
color = palette.textTertiary
)
}
}
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Text(
text = channel,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = if (isSelected) colorScheme.primary else palette.textPrimary,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
CloseButton(onClick = onLeaveChannel)
}
}
@ -316,6 +308,7 @@ fun PeopleSection(
colorScheme: ColorScheme,
selectedPrivatePeer: String?,
wifiAwarePeerIDs: Set<String> = emptySet(),
peopleCount: Int = 0,
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
@ -327,19 +320,31 @@ fun PeopleSection(
val palette = LocalBitchatPalette.current
Column(modifier = modifier) {
SheetSectionLabel(text = stringResource(R.string.people))
SheetIconSectionHeader(
icon = Icons.Filled.Person,
title = stringResource(R.string.people_count_title, peopleCount)
)
if (connectedPeers.isEmpty()) {
Text(
text = stringResource(id = R.string.no_one_connected),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = palette.textTertiary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding + 14.dp, vertical = 12.dp)
)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = palette.surface,
shape = AboutCardShape
) {
Column {
if (connectedPeers.isEmpty()) {
Text(
text = stringResource(id = R.string.no_one_connected),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = palette.textTertiary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical)
)
}
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
@ -443,7 +448,11 @@ fun PeopleSection(
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
var peerIndex = 0
sortedPeers.forEach { peerID ->
if (peerIndex > 0) SheetCardDivider()
peerIndex++
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val isFavorite = peerFavoriteStates[peerID] ?: false
val isVerified = peerVerifiedStates[peerID] ?: false
@ -492,6 +501,9 @@ fun PeopleSection(
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
if (isFavoriteMappedToConnected(fav)) return@forEach
if (peerIndex > 0) SheetCardDivider()
peerIndex++
val nostrConvKey: String? = try {
FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
?.let { ContactIdentityResolver.nostrAliasForPubkey(it) }
@ -535,6 +547,8 @@ fun PeopleSection(
showHashSuffix = showHash
)
}
}
}
}
}
@ -568,131 +582,112 @@ private fun PeerItem(
val assignedColor = viewModel.colorForMeshPeer(peerID, palette.isDark)
val baseColor = if (isMe) palette.accentOrange else assignedColor
Surface(
onClick = onItemClick,
color = if (isSelected) palette.surfaceVariant else palette.surface,
shape = RoundedCornerShape(10.dp),
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SheetHorizontalPadding, vertical = 3.dp)
.clickable(onClick = onItemClick)
.padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp)
.padding(start = 14.dp, end = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
Box(
modifier = Modifier.size(SheetRowLeadingSlot),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Connection/status indicator
if (hasUnreadDM) {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentOrange
)
} else if (showNostrGlobe) {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentPurple
)
} else if (!isDirect && isFavorite) {
// Offline favourite: routed glyph, dimmed to read as unavailable.
Icon(
imageVector = Icons.Outlined.Circle,
contentDescription = stringResource(R.string.cd_offline_favorite),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.textTertiary
)
} else {
Icon(
imageVector = when {
isWifiAware -> Icons.Filled.Wifi
isDirect -> Icons.Outlined.Bluetooth
else -> Icons.Filled.Route
},
contentDescription = when {
isWifiAware -> "Direct Wi-Fi Aware"
isDirect -> "Direct Bluetooth"
else -> "Routed"
},
modifier = Modifier.size(PeerRowIconSize),
tint = palette.textSecondary
)
}
// Display name with iOS-style color and hashtag suffix support
Row(verticalAlignment = Alignment.CenterVertically) {
// Base name with peer-specific color
Text(
text = baseName,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Hashtag suffix in lighter shade (iOS-style)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor.copy(alpha = SUFFIX_ALPHA)
)
}
if (isWifiAware && hasUnreadDM) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Wifi,
contentDescription = "Direct Wi-Fi Aware",
modifier = Modifier.size(PeerRowIconSize),
tint = palette.textSecondary
)
}
}
}
if (isVerified) {
Spacer(modifier = Modifier.width(4.dp))
if (isSelected) {
Box(
modifier = Modifier
.size(SheetRowSelectedDot)
.background(palette.accentGreen, CircleShape)
)
} else if (hasUnreadDM) {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(R.string.verify_title),
imageVector = Icons.Filled.Email,
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentGreen
tint = palette.accentOrange
)
} else if (showNostrGlobe) {
Icon(
imageVector = Icons.Filled.Public,
contentDescription = stringResource(R.string.cd_reachable_via_nostr),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.accentPurple
)
} else if (!isDirect && isFavorite) {
Icon(
imageVector = Icons.Outlined.Circle,
contentDescription = stringResource(R.string.cd_offline_favorite),
modifier = Modifier.size(PeerRowIconSize),
tint = palette.textTertiary
)
} else {
Icon(
imageVector = when {
isWifiAware -> Icons.Filled.Wifi
isDirect -> Icons.Outlined.Bluetooth
else -> Icons.Filled.Route
},
contentDescription = when {
isWifiAware -> "Direct Wi-Fi Aware"
isDirect -> "Direct Bluetooth"
else -> "Routed"
},
modifier = Modifier.size(PeerRowIconSize),
tint = palette.textSecondary
)
}
}
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = baseName,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (suffix.isNotEmpty()) {
Text(
text = suffix,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
color = baseColor.copy(alpha = SUFFIX_ALPHA)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Favorite star with proper filled/outlined states
IconButton(
onClick = onToggleFavorite,
modifier = Modifier.size(44.dp)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
modifier = Modifier.size(PeerRowIconSize),
tint = if (isFavorite) palette.accentOrange else palette.textTertiary
)
}
if (isVerified) {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = stringResource(R.string.verify_title),
modifier = Modifier.size(16.dp),
tint = palette.accentGreen
)
}
}
Box(
modifier = Modifier
.size(36.dp)
.clickable(onClick = onToggleFavorite),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
modifier = Modifier.size(PeerRowIconSize),
tint = if (isFavorite) palette.accentOrange else palette.textTertiary
)
}
}
}