user bottom sheet (#285)

* wip sheet

* longpress

* block wip

* blocking works
This commit is contained in:
callebtc
2025-08-23 00:26:18 +02:00
committed by GitHub
parent c9c02d993e
commit 49c42ba169
7 changed files with 446 additions and 22 deletions
@@ -28,7 +28,8 @@ class NostrGeohashService(
private val messageManager: MessageManager,
private val privateChatManager: PrivateChatManager,
private val meshDelegateHandler: MeshDelegateHandler,
private val coroutineScope: CoroutineScope
private val coroutineScope: CoroutineScope,
private val dataManager: com.bitchat.android.ui.DataManager
) {
companion object {
@@ -49,7 +50,8 @@ class NostrGeohashService(
messageManager: MessageManager,
privateChatManager: PrivateChatManager,
meshDelegateHandler: MeshDelegateHandler,
coroutineScope: CoroutineScope
coroutineScope: CoroutineScope,
dataManager: com.bitchat.android.ui.DataManager
): NostrGeohashService {
return synchronized(this) {
INSTANCE ?: NostrGeohashService(
@@ -58,7 +60,8 @@ class NostrGeohashService(
messageManager,
privateChatManager,
meshDelegateHandler,
coroutineScope
coroutineScope,
dataManager
).also { INSTANCE = it }
}
}
@@ -914,6 +917,12 @@ class NostrGeohashService(
return
}
// Check if this user is blocked in geohash channels BEFORE any processing
if (isGeohashUserBlocked(event.pubkey)) {
Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...")
return
}
// Deduplicate events
if (processedNostrEvents.contains(event.id)) {
Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...")
@@ -1263,5 +1272,50 @@ class NostrGeohashService(
}
}
}
// MARK: - Geohash Blocking
/**
* Block a user in geohash channels by their nickname
*/
fun blockUserInGeohash(targetNickname: String) {
// Find the pubkey for this nickname
val pubkeyHex = geoNicknames.entries.firstOrNull { (_, nickname) ->
val baseName = nickname.split("#").firstOrNull() ?: nickname
baseName == targetNickname
}?.key
if (pubkeyHex != null) {
// Add to geohash block list
dataManager.addGeohashBlockedUser(pubkeyHex)
// Add system message
val systemMessage = com.bitchat.android.model.BitchatMessage(
sender = "system",
content = "blocked $targetNickname in geohash channels",
timestamp = java.util.Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
Log.i(TAG, "🚫 Blocked geohash user: $targetNickname (pubkey: ${pubkeyHex.take(8)}...)")
} else {
// User not found
val systemMessage = com.bitchat.android.model.BitchatMessage(
sender = "system",
content = "user '$targetNickname' not found in current geohash",
timestamp = java.util.Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
/**
* Check if a user is blocked in geohash channels
*/
private fun isGeohashUserBlocked(pubkeyHex: String): Boolean {
return dataManager.isGeohashUserBlocked(pubkeyHex)
}
}
@@ -50,6 +50,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
var showPasswordDialog by remember { mutableStateOf(false) }
var passwordInput by remember { mutableStateOf("") }
var showLocationChannelsSheet by remember { mutableStateOf(false) }
var showUserSheet by remember { mutableStateOf(false) }
var selectedUserForSheet by remember { mutableStateOf("") }
var forceScrollToBottom by remember { mutableStateOf(false) }
// Show password dialog when needed
@@ -87,7 +89,42 @@ fun ChatScreen(viewModel: ChatViewModel) {
currentUserNickname = nickname,
meshService = viewModel.meshService,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom
forceScrollToBottom = forceScrollToBottom,
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
// Extract base nickname and hash suffix from full sender name
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
// Check if we're in a geohash channel to include hash suffix
val selectedLocationChannel = viewModel.selectedLocationChannel.value
val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) {
// In geohash chat - include the hash suffix from the full display name
"@$baseName$hashSuffix"
} else {
// Regular chat - just the base nickname
"@$baseName"
}
val newText = when {
currentText.isEmpty() -> "$mentionText "
currentText.endsWith(" ") -> "$currentText$mentionText "
else -> "$currentText $mentionText "
}
messageText = TextFieldValue(
text = newText,
selection = TextRange(newText.length)
)
},
onNicknameLongPress = { fullSenderName ->
// Long press - open user action sheet
// Extract base nickname from full sender name
val (baseName, _) = splitSuffix(fullSenderName)
selectedUserForSheet = baseName
showUserSheet = true
}
)
// Input area - stays at bottom
ChatInputSection(
@@ -205,6 +242,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
onAppInfoDismiss = { viewModel.hideAppInfo() },
showLocationChannelsSheet = showLocationChannelsSheet,
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
showUserSheet = showUserSheet,
onUserSheetDismiss = { showUserSheet = false },
selectedUserForSheet = selectedUserForSheet,
viewModel = viewModel
)
}
@@ -337,6 +377,9 @@ private fun ChatDialogs(
onAppInfoDismiss: () -> Unit,
showLocationChannelsSheet: Boolean,
onLocationChannelsSheetDismiss: () -> Unit,
showUserSheet: Boolean,
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
viewModel: ChatViewModel
) {
// Password dialog
@@ -363,4 +406,14 @@ private fun ChatDialogs(
viewModel = viewModel
)
}
// User action sheet
if (showUserSheet) {
ChatUserSheet(
isPresented = showUserSheet,
onDismiss = onUserSheetDismiss,
targetNickname = selectedUserForSheet,
viewModel = viewModel
)
}
}
@@ -69,13 +69,25 @@ fun formatMessageAsAnnotatedString(
builder.append("<@")
builder.pop()
// Base name
// Base name (clickable)
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = 14.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
val nicknameStart = builder.length
builder.append(baseName)
val nicknameEnd = builder.length
// Add click annotation for nickname (store full sender name with hash)
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = message.sender, // Store full sender name with hash
start = nicknameStart,
end = nicknameEnd
)
}
builder.pop()
// Hashtag suffix in lighter color (iOS style)
@@ -0,0 +1,181 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
/**
* User Action Sheet for selecting actions on a specific user (slap, hug, block)
* Design language matches LocationChannelsSheet.kt for consistency
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatUserSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
targetNickname: String,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
// iOS system colors (matches LocationChannelsSheet 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
val standardRed = Color(0xFFFF3B30) // iOS red
if (isPresented) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Header
Text(
text = "@$targetNickname",
fontSize = 18.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "choose an action for this user",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// Action list (iOS-style plain list)
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
// Slap action
item {
UserActionRow(
title = "slap $targetNickname",
subtitle = "send a playful slap message",
titleColor = standardBlue,
onClick = {
// Send slap command
viewModel.sendMessage("/slap $targetNickname")
onDismiss()
}
)
}
// Hug action
item {
UserActionRow(
title = "hug $targetNickname",
subtitle = "send a friendly hug message",
titleColor = standardGreen,
onClick = {
// Send hug command
viewModel.sendMessage("/hug $targetNickname")
onDismiss()
}
)
}
// Block action
item {
UserActionRow(
title = "block $targetNickname",
subtitle = "block all messages from this user",
titleColor = standardRed,
onClick = {
// Check if we're in a geohash channel
val selectedLocationChannel = viewModel.selectedLocationChannel.value
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
// Get user's nostr public key and add to geohash block list
viewModel.blockUserInGeohash(targetNickname)
} else {
// Regular mesh blocking
viewModel.sendMessage("/block $targetNickname")
}
onDismiss()
}
)
}
}
// Cancel button (iOS-style)
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "cancel",
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
@Composable
private fun UserActionRow(
title: String,
subtitle: String,
titleColor: Color,
onClick: () -> Unit
) {
// iOS-style list row (plain button, no card background)
Surface(
onClick = onClick,
color = Color.Transparent,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = title,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
color = titleColor
)
Text(
text = subtitle,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
@@ -68,7 +68,8 @@ class ChatViewModel(
messageManager = messageManager,
privateChatManager = privateChatManager,
meshDelegateHandler = meshDelegateHandler,
coroutineScope = viewModelScope
coroutineScope = viewModelScope,
dataManager = dataManager
)
// Expose state through LiveData (maintaining the same interface)
@@ -133,6 +134,7 @@ class ChatViewModel(
dataManager.loadFavorites()
state.setFavoritePeers(dataManager.favoritePeers.toSet())
dataManager.loadBlockedUsers()
dataManager.loadGeohashBlockedUsers()
// Log all favorites at startup
dataManager.logAllFavorites()
@@ -583,6 +585,13 @@ class ChatViewModel(
nostrGeohashService.selectLocationChannel(channel)
}
/**
* Block a user in geohash channels by their nickname
*/
fun blockUserInGeohash(targetNickname: String) {
nostrGeohashService.blockUserInGeohash(targetNickname)
}
@@ -209,12 +209,41 @@ class DataManager(private val context: Context) {
return _blockedUsers.contains(fingerprint)
}
// MARK: - Geohash Blocked Users Management
private val _geohashBlockedUsers = mutableSetOf<String>() // Set of nostr pubkey hex
val geohashBlockedUsers: Set<String> get() = _geohashBlockedUsers.toSet()
fun loadGeohashBlockedUsers() {
val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet()
_geohashBlockedUsers.addAll(savedGeohashBlockedUsers)
}
fun saveGeohashBlockedUsers() {
prefs.edit().putStringSet("geohash_blocked_users", _geohashBlockedUsers).apply()
}
fun addGeohashBlockedUser(pubkeyHex: String) {
_geohashBlockedUsers.add(pubkeyHex)
saveGeohashBlockedUsers()
}
fun removeGeohashBlockedUser(pubkeyHex: String) {
_geohashBlockedUsers.remove(pubkeyHex)
saveGeohashBlockedUsers()
}
fun isGeohashUserBlocked(pubkeyHex: String): Boolean {
return _geohashBlockedUsers.contains(pubkeyHex)
}
// MARK: - Emergency Clear
fun clearAllData() {
_channelCreators.clear()
_favoritePeers.clear()
_blockedUsers.clear()
_geohashBlockedUsers.clear()
_channelMembers.clear()
prefs.edit().clear().apply()
}
@@ -1,9 +1,12 @@
package com.bitchat.android.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -32,7 +35,9 @@ fun MessagesList(
currentUserNickname: String,
meshService: BluetoothMeshService,
modifier: Modifier = Modifier,
forceScrollToBottom: Boolean = false
forceScrollToBottom: Boolean = false,
onNicknameClick: ((String) -> Unit)? = null,
onNicknameLongPress: ((String) -> Unit)? = null
) {
val listState = rememberLazyListState()
@@ -76,18 +81,23 @@ fun MessagesList(
MessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService
meshService = meshService,
onNicknameClick = onNicknameClick,
onNicknameLongPress = onNicknameLongPress
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageItem(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService
meshService: BluetoothMeshService,
onNicknameClick: ((String) -> Unit)? = null,
onNicknameLongPress: ((String) -> Unit)? = null
) {
val colorScheme = MaterialTheme.colorScheme
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
@@ -97,19 +107,16 @@ fun MessageItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
// Single text view for natural wrapping (like iOS)
Text(
text = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
),
modifier = Modifier.weight(1f),
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible
// Create a custom layout that combines selectable text with clickable nickname areas
MessageTextWithClickableNicknames(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
onNicknameClick = onNicknameClick,
onNicknameLongPress = onNicknameLongPress,
modifier = Modifier.weight(1f)
)
// Delivery status for private messages
@@ -121,6 +128,85 @@ fun MessageItem(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageTextWithClickableNicknames(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onNicknameLongPress: ((String) -> Unit)?,
modifier: Modifier = Modifier
) {
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
// Check if this message was sent by self to avoid click interactions on own nickname
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
if (!isSelf && (onNicknameClick != null || onNicknameLongPress != null)) {
// Use Text with combinedClickable for nickname interactions
Text(
text = annotatedText,
modifier = modifier.combinedClickable(
onClick = {
// We can't get the click offset here, so we'll handle the first nickname
val nicknameAnnotations = annotatedText.getStringAnnotations(
tag = "nickname_click",
start = 0,
end = annotatedText.length
)
if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
onNicknameClick?.invoke(nickname)
}
},
onLongClick = {
// Handle long press for the first nickname
val nicknameAnnotations = annotatedText.getStringAnnotations(
tag = "nickname_click",
start = 0,
end = annotatedText.length
)
if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
onNicknameLongPress?.invoke(nickname)
}
}
),
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
)
)
} else {
// Use selectable text when no interactions needed
SelectionContainer {
Text(
text = annotatedText,
modifier = modifier,
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
)
)
}
}
}
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme