diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index b70743ca..57357a9c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -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) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 9d7f044c..1f121da0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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 + ) + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index b35ff16c..ddaf4110 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt new file mode 100644 index 00000000..9ac8c11d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index b831975b..614d235f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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) + } + diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index ef342332..0ed7d690 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -209,12 +209,41 @@ class DataManager(private val context: Context) { return _blockedUsers.contains(fingerprint) } + // MARK: - Geohash Blocked Users Management + + private val _geohashBlockedUsers = mutableSetOf() // Set of nostr pubkey hex + val geohashBlockedUsers: Set 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() } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index d6ae9298..57e4db22 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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