mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 02:25:21 +00:00
user bottom sheet (#285)
* wip sheet * longpress * block wip * blocking works
This commit is contained in:
@@ -28,7 +28,8 @@ class NostrGeohashService(
|
|||||||
private val messageManager: MessageManager,
|
private val messageManager: MessageManager,
|
||||||
private val privateChatManager: PrivateChatManager,
|
private val privateChatManager: PrivateChatManager,
|
||||||
private val meshDelegateHandler: MeshDelegateHandler,
|
private val meshDelegateHandler: MeshDelegateHandler,
|
||||||
private val coroutineScope: CoroutineScope
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val dataManager: com.bitchat.android.ui.DataManager
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -49,7 +50,8 @@ class NostrGeohashService(
|
|||||||
messageManager: MessageManager,
|
messageManager: MessageManager,
|
||||||
privateChatManager: PrivateChatManager,
|
privateChatManager: PrivateChatManager,
|
||||||
meshDelegateHandler: MeshDelegateHandler,
|
meshDelegateHandler: MeshDelegateHandler,
|
||||||
coroutineScope: CoroutineScope
|
coroutineScope: CoroutineScope,
|
||||||
|
dataManager: com.bitchat.android.ui.DataManager
|
||||||
): NostrGeohashService {
|
): NostrGeohashService {
|
||||||
return synchronized(this) {
|
return synchronized(this) {
|
||||||
INSTANCE ?: NostrGeohashService(
|
INSTANCE ?: NostrGeohashService(
|
||||||
@@ -58,7 +60,8 @@ class NostrGeohashService(
|
|||||||
messageManager,
|
messageManager,
|
||||||
privateChatManager,
|
privateChatManager,
|
||||||
meshDelegateHandler,
|
meshDelegateHandler,
|
||||||
coroutineScope
|
coroutineScope,
|
||||||
|
dataManager
|
||||||
).also { INSTANCE = it }
|
).also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -914,6 +917,12 @@ class NostrGeohashService(
|
|||||||
return
|
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
|
// Deduplicate events
|
||||||
if (processedNostrEvents.contains(event.id)) {
|
if (processedNostrEvents.contains(event.id)) {
|
||||||
Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...")
|
Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...")
|
||||||
@@ -1264,4 +1273,49 @@ 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 showPasswordDialog by remember { mutableStateOf(false) }
|
||||||
var passwordInput by remember { mutableStateOf("") }
|
var passwordInput by remember { mutableStateOf("") }
|
||||||
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
||||||
|
var showUserSheet by remember { mutableStateOf(false) }
|
||||||
|
var selectedUserForSheet by remember { mutableStateOf("") }
|
||||||
var forceScrollToBottom by remember { mutableStateOf(false) }
|
var forceScrollToBottom by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Show password dialog when needed
|
// Show password dialog when needed
|
||||||
@@ -87,7 +89,42 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
currentUserNickname = nickname,
|
currentUserNickname = nickname,
|
||||||
meshService = viewModel.meshService,
|
meshService = viewModel.meshService,
|
||||||
modifier = Modifier.weight(1f),
|
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
|
// Input area - stays at bottom
|
||||||
ChatInputSection(
|
ChatInputSection(
|
||||||
@@ -205,6 +242,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
||||||
showLocationChannelsSheet = showLocationChannelsSheet,
|
showLocationChannelsSheet = showLocationChannelsSheet,
|
||||||
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
||||||
|
showUserSheet = showUserSheet,
|
||||||
|
onUserSheetDismiss = { showUserSheet = false },
|
||||||
|
selectedUserForSheet = selectedUserForSheet,
|
||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -337,6 +377,9 @@ private fun ChatDialogs(
|
|||||||
onAppInfoDismiss: () -> Unit,
|
onAppInfoDismiss: () -> Unit,
|
||||||
showLocationChannelsSheet: Boolean,
|
showLocationChannelsSheet: Boolean,
|
||||||
onLocationChannelsSheetDismiss: () -> Unit,
|
onLocationChannelsSheetDismiss: () -> Unit,
|
||||||
|
showUserSheet: Boolean,
|
||||||
|
onUserSheetDismiss: () -> Unit,
|
||||||
|
selectedUserForSheet: String,
|
||||||
viewModel: ChatViewModel
|
viewModel: ChatViewModel
|
||||||
) {
|
) {
|
||||||
// Password dialog
|
// Password dialog
|
||||||
@@ -363,4 +406,14 @@ private fun ChatDialogs(
|
|||||||
viewModel = viewModel
|
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.append("<@")
|
||||||
builder.pop()
|
builder.pop()
|
||||||
|
|
||||||
// Base name
|
// Base name (clickable)
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = baseColor,
|
color = baseColor,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||||
))
|
))
|
||||||
|
val nicknameStart = builder.length
|
||||||
builder.append(baseName)
|
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()
|
builder.pop()
|
||||||
|
|
||||||
// Hashtag suffix in lighter color (iOS style)
|
// 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,
|
messageManager = messageManager,
|
||||||
privateChatManager = privateChatManager,
|
privateChatManager = privateChatManager,
|
||||||
meshDelegateHandler = meshDelegateHandler,
|
meshDelegateHandler = meshDelegateHandler,
|
||||||
coroutineScope = viewModelScope
|
coroutineScope = viewModelScope,
|
||||||
|
dataManager = dataManager
|
||||||
)
|
)
|
||||||
|
|
||||||
// Expose state through LiveData (maintaining the same interface)
|
// Expose state through LiveData (maintaining the same interface)
|
||||||
@@ -133,6 +134,7 @@ class ChatViewModel(
|
|||||||
dataManager.loadFavorites()
|
dataManager.loadFavorites()
|
||||||
state.setFavoritePeers(dataManager.favoritePeers.toSet())
|
state.setFavoritePeers(dataManager.favoritePeers.toSet())
|
||||||
dataManager.loadBlockedUsers()
|
dataManager.loadBlockedUsers()
|
||||||
|
dataManager.loadGeohashBlockedUsers()
|
||||||
|
|
||||||
// Log all favorites at startup
|
// Log all favorites at startup
|
||||||
dataManager.logAllFavorites()
|
dataManager.logAllFavorites()
|
||||||
@@ -583,6 +585,13 @@ class ChatViewModel(
|
|||||||
nostrGeohashService.selectLocationChannel(channel)
|
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)
|
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
|
// MARK: - Emergency Clear
|
||||||
|
|
||||||
fun clearAllData() {
|
fun clearAllData() {
|
||||||
_channelCreators.clear()
|
_channelCreators.clear()
|
||||||
_favoritePeers.clear()
|
_favoritePeers.clear()
|
||||||
_blockedUsers.clear()
|
_blockedUsers.clear()
|
||||||
|
_geohashBlockedUsers.clear()
|
||||||
_channelMembers.clear()
|
_channelMembers.clear()
|
||||||
prefs.edit().clear().apply()
|
prefs.edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -32,7 +35,9 @@ fun MessagesList(
|
|||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService,
|
meshService: BluetoothMeshService,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
forceScrollToBottom: Boolean = false
|
forceScrollToBottom: Boolean = false,
|
||||||
|
onNicknameClick: ((String) -> Unit)? = null,
|
||||||
|
onNicknameLongPress: ((String) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
@@ -76,18 +81,23 @@ fun MessagesList(
|
|||||||
MessageItem(
|
MessageItem(
|
||||||
message = message,
|
message = message,
|
||||||
currentUserNickname = currentUserNickname,
|
currentUserNickname = currentUserNickname,
|
||||||
meshService = meshService
|
meshService = meshService,
|
||||||
|
onNicknameClick = onNicknameClick,
|
||||||
|
onNicknameLongPress = onNicknameLongPress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageItem(
|
fun MessageItem(
|
||||||
message: BitchatMessage,
|
message: BitchatMessage,
|
||||||
currentUserNickname: String,
|
currentUserNickname: String,
|
||||||
meshService: BluetoothMeshService
|
meshService: BluetoothMeshService,
|
||||||
|
onNicknameClick: ((String) -> Unit)? = null,
|
||||||
|
onNicknameLongPress: ((String) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||||
@@ -97,19 +107,16 @@ fun MessageItem(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
// Single text view for natural wrapping (like iOS)
|
// Create a custom layout that combines selectable text with clickable nickname areas
|
||||||
Text(
|
MessageTextWithClickableNicknames(
|
||||||
text = formatMessageAsAnnotatedString(
|
|
||||||
message = message,
|
message = message,
|
||||||
currentUserNickname = currentUserNickname,
|
currentUserNickname = currentUserNickname,
|
||||||
meshService = meshService,
|
meshService = meshService,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
timeFormatter = timeFormatter
|
timeFormatter = timeFormatter,
|
||||||
),
|
onNicknameClick = onNicknameClick,
|
||||||
modifier = Modifier.weight(1f),
|
onNicknameLongPress = onNicknameLongPress,
|
||||||
fontFamily = FontFamily.Monospace,
|
modifier = Modifier.weight(1f)
|
||||||
softWrap = true,
|
|
||||||
overflow = TextOverflow.Visible
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Delivery status for private messages
|
// 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
|
@Composable
|
||||||
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|||||||
Reference in New Issue
Block a user