mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 06:05:21 +00:00
UI geohash notifications (#325)
* location name in notifications * remove tests * panic nostr * mentions with hashes, otherwise none * fix timestamps * parse geohashes in messages * works * fix country name * mention notifications work
This commit is contained in:
@@ -88,12 +88,12 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create BitchatMessage - use local system time for incoming messages
|
// Create BitchatMessage - preserve source packet timestamp
|
||||||
val message = BitchatMessage(
|
val message = BitchatMessage(
|
||||||
id = privateMessage.messageID,
|
id = privateMessage.messageID,
|
||||||
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
|
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
|
||||||
content = privateMessage.content,
|
content = privateMessage.content,
|
||||||
timestamp = java.util.Date(), // Use local system time instead of packet timestamp
|
timestamp = java.util.Date(packet.timestamp.toLong()),
|
||||||
isRelay = false,
|
isRelay = false,
|
||||||
originalSender = null,
|
originalSender = null,
|
||||||
isPrivate = true,
|
isPrivate = true,
|
||||||
@@ -343,7 +343,7 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||||
content = String(packet.payload, Charsets.UTF_8),
|
content = String(packet.payload, Charsets.UTF_8),
|
||||||
senderPeerID = peerID,
|
senderPeerID = peerID,
|
||||||
timestamp = Date()
|
timestamp = Date(packet.timestamp.toLong())
|
||||||
)
|
)
|
||||||
|
|
||||||
delegate?.onMessageReceived(message)
|
delegate?.onMessageReceived(message)
|
||||||
@@ -369,7 +369,7 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||||
content = String(packet.payload, Charsets.UTF_8),
|
content = String(packet.payload, Charsets.UTF_8),
|
||||||
senderPeerID = peerID,
|
senderPeerID = peerID,
|
||||||
timestamp = Date()
|
timestamp = Date(packet.timestamp.toLong())
|
||||||
)
|
)
|
||||||
delegate?.onMessageReceived(message)
|
delegate?.onMessageReceived(message)
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,76 @@ class NostrGeohashService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panic/reset flow for Nostr + geohash systems.
|
||||||
|
* - Disconnect and clear all Nostr relay subscriptions and caches
|
||||||
|
* - Delete device-wide Nostr keys and per-geohash seed cache
|
||||||
|
* - Clear geohash participants, nicknames, message history
|
||||||
|
* - Recreate identity and reconnect; reinitialize subscriptions from scratch
|
||||||
|
*/
|
||||||
|
fun panicResetNostrAndGeohash() {
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val relayManager = NostrRelayManager.getInstance(application)
|
||||||
|
|
||||||
|
// 1) Disconnect from all relays
|
||||||
|
relayManager.disconnect()
|
||||||
|
|
||||||
|
// 2) Clear all subscription tracking and caches
|
||||||
|
relayManager.clearAllSubscriptions()
|
||||||
|
|
||||||
|
// 3) Clear Nostr identity (npub/private) and geohash identity cache/seed
|
||||||
|
try {
|
||||||
|
NostrIdentityBridge.clearAllAssociations(application)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to clear Nostr associations: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Clear local geohash state (participants, nicknames, message history)
|
||||||
|
try {
|
||||||
|
geohashParticipants.clear()
|
||||||
|
geoNicknames.clear()
|
||||||
|
geohashMessageHistory.clear()
|
||||||
|
processedNostrEvents.clear()
|
||||||
|
processedNostrEventOrder.clear()
|
||||||
|
currentGeohashSubscriptionId = null
|
||||||
|
currentGeohashDmSubscriptionId = null
|
||||||
|
currentGeohash = null
|
||||||
|
state.setGeohashPeople(emptyList())
|
||||||
|
state.setTeleportedGeo(emptySet())
|
||||||
|
state.setGeohashParticipantCounts(emptyMap())
|
||||||
|
// Stop any timers/jobs
|
||||||
|
geohashSamplingJob?.cancel()
|
||||||
|
geohashSamplingJob = null
|
||||||
|
geoParticipantsTimer?.cancel()
|
||||||
|
geoParticipantsTimer = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to clear geohash state: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Recreate identity and reconnect, then re-subscribe
|
||||||
|
try {
|
||||||
|
// Touch identity to recreate
|
||||||
|
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application)
|
||||||
|
if (identity == null) {
|
||||||
|
Log.w(TAG, "No identity after reset; skipping subscriptions")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect to relays and reinitialize subscriptions
|
||||||
|
relayManager.connect()
|
||||||
|
initializeNostrIntegration()
|
||||||
|
// Re-establish location channel state observers and subscriptions
|
||||||
|
initializeLocationChannelState()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to reinitialize Nostr after reset: ${e.message}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "panicResetNostrAndGeohash error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize location channel state
|
* Initialize location channel state
|
||||||
*/
|
*/
|
||||||
@@ -610,7 +680,6 @@ class NostrGeohashService(
|
|||||||
private fun storeGeohashMessage(geohash: String, message: BitchatMessage) {
|
private fun storeGeohashMessage(geohash: String, message: BitchatMessage) {
|
||||||
val messages = geohashMessageHistory.getOrPut(geohash) { mutableListOf() }
|
val messages = geohashMessageHistory.getOrPut(geohash) { mutableListOf() }
|
||||||
messages.add(message)
|
messages.add(message)
|
||||||
messages.sortBy { it.timestamp }
|
|
||||||
|
|
||||||
// Limit message history to prevent memory issues
|
// Limit message history to prevent memory issues
|
||||||
if (messages.size > maxGeohashMessages) {
|
if (messages.size > maxGeohashMessages) {
|
||||||
@@ -1160,11 +1229,12 @@ class NostrGeohashService(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val senderName = displayNameForNostrPubkey(event.pubkey)
|
val senderHandle = displayNameForNostrPubkey(event.pubkey)
|
||||||
|
val senderName = displayNameForNostrPubkeyUI(event.pubkey)
|
||||||
val content = event.content
|
val content = event.content
|
||||||
|
|
||||||
// Use local time instead of Nostr event time for consistent message ordering
|
// Preserve Nostr event time to avoid reordering
|
||||||
val messageTimestamp = Date()
|
val messageTimestamp = Date(event.createdAt.toLong() * 1000L)
|
||||||
// Note: mentions parsing needs peer nicknames parameter
|
// Note: mentions parsing needs peer nicknames parameter
|
||||||
// val mentions = messageManager.parseMentions(content, peerNicknames, nickname)
|
// val mentions = messageManager.parseMentions(content, peerNicknames, nickname)
|
||||||
|
|
||||||
@@ -1174,6 +1244,7 @@ class NostrGeohashService(
|
|||||||
content = content,
|
content = content,
|
||||||
timestamp = messageTimestamp,
|
timestamp = messageTimestamp,
|
||||||
isRelay = false,
|
isRelay = false,
|
||||||
|
originalSender = senderHandle,
|
||||||
senderPeerID = "nostr:${event.pubkey.take(8)}",
|
senderPeerID = "nostr:${event.pubkey.take(8)}",
|
||||||
mentions = null, // mentions need to be passed from outside
|
mentions = null, // mentions need to be passed from outside
|
||||||
channel = "#$geohash"
|
channel = "#$geohash"
|
||||||
@@ -1231,12 +1302,32 @@ class NostrGeohashService(
|
|||||||
Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage")
|
Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage")
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
// Sanitize mention for notifications: hide '#hash' from @username#hash if not needed
|
||||||
|
val contentForNotification = if (isMention) {
|
||||||
|
var sanitized = content
|
||||||
|
try {
|
||||||
|
val myGeoIdentity = NostrIdentityBridge.deriveIdentity(
|
||||||
|
forGeohash = geohash,
|
||||||
|
context = application
|
||||||
|
)
|
||||||
|
val myDisplay = displayNameForNostrPubkeyUI(myGeoIdentity.publicKeyHex)
|
||||||
|
val needsDisambiguation = myDisplay.contains("#")
|
||||||
|
if (!needsDisambiguation) {
|
||||||
|
val pattern = ("@" + java.util.regex.Pattern.quote(currentNickname) + "#[a-fA-F0-9]{4}\\b").toRegex()
|
||||||
|
sanitized = sanitized.replace(pattern, "@" + currentNickname)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
sanitized
|
||||||
|
} else content
|
||||||
|
|
||||||
|
val locationName = getLocationNameForGeohash(geohash)
|
||||||
notificationManager.showGeohashNotification(
|
notificationManager.showGeohashNotification(
|
||||||
geohash = geohash,
|
geohash = geohash,
|
||||||
senderNickname = senderName,
|
senderNickname = senderName,
|
||||||
messageContent = content,
|
messageContent = contentForNotification,
|
||||||
isMention = isMention,
|
isMention = isMention,
|
||||||
isFirstMessage = isFirstMessage
|
isFirstMessage = isFirstMessage,
|
||||||
|
locationName = locationName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1365,7 +1456,8 @@ class NostrGeohashService(
|
|||||||
}
|
}
|
||||||
if (messageExists) return@launch
|
if (messageExists) return@launch
|
||||||
|
|
||||||
val senderName = displayNameForNostrPubkey(senderPubkey)
|
val senderHandle = displayNameForNostrPubkey(senderPubkey)
|
||||||
|
val senderName = displayNameForNostrPubkeyUI(senderPubkey)
|
||||||
val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey
|
val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey
|
||||||
|
|
||||||
val message = BitchatMessage(
|
val message = BitchatMessage(
|
||||||
@@ -1377,6 +1469,7 @@ class NostrGeohashService(
|
|||||||
isPrivate = true,
|
isPrivate = true,
|
||||||
recipientNickname = state.getNicknameValue(),
|
recipientNickname = state.getNicknameValue(),
|
||||||
senderPeerID = convKey,
|
senderPeerID = convKey,
|
||||||
|
originalSender = senderHandle,
|
||||||
deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered(
|
deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered(
|
||||||
to = state.getNicknameValue() ?: "Unknown",
|
to = state.getNicknameValue() ?: "Unknown",
|
||||||
at = Date()
|
at = Date()
|
||||||
@@ -1454,6 +1547,71 @@ class NostrGeohashService(
|
|||||||
return "anon#$suffix"
|
return "anon#$suffix"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI display name for Nostr pubkey (conditionally hides #hash when not needed)
|
||||||
|
* Used in chat window sender labels, geohash people list, and notifications.
|
||||||
|
*/
|
||||||
|
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
|
||||||
|
val pubkeyLower = pubkeyHex.lowercase()
|
||||||
|
val suffix = pubkeyHex.takeLast(4)
|
||||||
|
val currentGeohash = this.currentGeohash
|
||||||
|
|
||||||
|
// Determine base name (self nickname, cached nickname, or anon)
|
||||||
|
val baseName: String = try {
|
||||||
|
if (currentGeohash != null) {
|
||||||
|
val myGeoIdentity = NostrIdentityBridge.deriveIdentity(
|
||||||
|
forGeohash = currentGeohash,
|
||||||
|
context = application
|
||||||
|
)
|
||||||
|
if (myGeoIdentity.publicKeyHex.lowercase() == pubkeyLower) {
|
||||||
|
state.getNicknameValue() ?: "anon"
|
||||||
|
} else {
|
||||||
|
geoNicknames[pubkeyLower] ?: "anon"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
geoNicknames[pubkeyLower] ?: "anon"
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
geoNicknames[pubkeyLower] ?: "anon"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in a geohash context, no need for disambiguation
|
||||||
|
if (currentGeohash == null) return baseName
|
||||||
|
|
||||||
|
// Count active participants sharing the same base name within 5 minutes
|
||||||
|
return try {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[currentGeohash] ?: emptyMap()
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
for ((participantKey, lastSeen) in participants) {
|
||||||
|
if (lastSeen.before(cutoff)) continue
|
||||||
|
val participantLower = participantKey.lowercase()
|
||||||
|
val name = if (participantLower == pubkeyLower) baseName else (geoNicknames[participantLower] ?: "anon")
|
||||||
|
if (name.equals(baseName, ignoreCase = true)) {
|
||||||
|
count++
|
||||||
|
if (count > 1) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!participants.containsKey(pubkeyLower)) {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 1) "$baseName#$suffix" else baseName
|
||||||
|
} catch (_: Exception) {
|
||||||
|
baseName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mention handle for a Nostr pubkey (ALWAYS includes #hash)
|
||||||
|
* Use for mentions, hug/slap targets, and mention detection contexts.
|
||||||
|
*/
|
||||||
|
fun mentionHandleForNostrPubkey(pubkeyHex: String): String {
|
||||||
|
return displayNameForNostrPubkey(pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Color System
|
// MARK: - Color System
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1606,4 +1764,36 @@ class NostrGeohashService(
|
|||||||
return dataManager.isGeohashUserBlocked(pubkeyHex)
|
return dataManager.isGeohashUserBlocked(pubkeyHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location name for a geohash if available
|
||||||
|
*/
|
||||||
|
private fun getLocationNameForGeohash(geohash: String): String? {
|
||||||
|
return try {
|
||||||
|
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application)
|
||||||
|
val locationNames = locationManager.locationNames.value ?: return null
|
||||||
|
val available = locationManager.availableChannels.value ?: emptyList()
|
||||||
|
|
||||||
|
// Determine the level from geohash length
|
||||||
|
val level = when (geohash.length) {
|
||||||
|
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
||||||
|
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
||||||
|
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
||||||
|
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
||||||
|
7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||||
|
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show location name if the notification's geohash matches the device's current geohash at that level
|
||||||
|
val currentAtLevel = available.firstOrNull { it.level == level }?.geohash
|
||||||
|
if (currentAtLevel != null && currentAtLevel.equals(geohash, ignoreCase = true)) {
|
||||||
|
locationNames[level]
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to get location name for geohash $geohash: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -456,6 +456,31 @@ class NostrRelayManager private constructor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all subscription tracking, message handlers, routing caches, and queued messages.
|
||||||
|
* Intended for panic/reset flows prior to reconnecting and re-subscribing from scratch.
|
||||||
|
*/
|
||||||
|
fun clearAllSubscriptions() {
|
||||||
|
try {
|
||||||
|
// Clear persistent subscription tracking
|
||||||
|
activeSubscriptions.clear()
|
||||||
|
messageHandlers.clear()
|
||||||
|
subscriptions.clear()
|
||||||
|
|
||||||
|
// Clear routing caches (per-geohash relay selections)
|
||||||
|
geohashToRelays.clear()
|
||||||
|
|
||||||
|
// Clear any queued messages waiting to be sent
|
||||||
|
synchronized(messageQueueLock) {
|
||||||
|
messageQueue.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "🧹 Cleared all Nostr subscriptions and routing caches")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to clear subscriptions: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get detailed status for all relays
|
* Get detailed status for all relays
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ private fun LocationDisabledContent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "• Bluetooth device scanning (Android requirement)\n" +
|
text = "• Bluetooth device scanning\n" +
|
||||||
"• Discovering nearby users on mesh network\n" +
|
"• Discovering nearby users on mesh network\n" +
|
||||||
"• Geohash chat feature\n" +
|
"• Geohash chat feature\n" +
|
||||||
"• No tracking or location collection",
|
"• No tracking or location collection",
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ object ConversationAliasResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (didMerge) {
|
if (didMerge) {
|
||||||
targetList.sortBy { it.timestamp }
|
// Preserve arrival order; do not sort by timestamp
|
||||||
currentChats[targetPeerID] = targetList
|
currentChats[targetPeerID] = targetList
|
||||||
state.setPrivateChats(currentChats)
|
state.setPrivateChats(currentChats)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.text.AnnotatedString
|
|||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
@@ -80,11 +81,11 @@ fun formatMessageAsAnnotatedString(
|
|||||||
builder.append(baseName)
|
builder.append(baseName)
|
||||||
val nicknameEnd = builder.length
|
val nicknameEnd = builder.length
|
||||||
|
|
||||||
// Add click annotation for nickname (store full sender name with hash)
|
// Add click annotation for nickname (store canonical sender name with hash if available)
|
||||||
if (!isSelf) {
|
if (!isSelf) {
|
||||||
builder.addStringAnnotation(
|
builder.addStringAnnotation(
|
||||||
tag = "nickname_click",
|
tag = "nickname_click",
|
||||||
annotation = message.sender, // Store full sender name with hash
|
annotation = (message.originalSender ?: message.sender),
|
||||||
start = nicknameStart,
|
start = nicknameStart,
|
||||||
end = nicknameEnd
|
end = nicknameEnd
|
||||||
)
|
)
|
||||||
@@ -258,6 +259,31 @@ private fun appendIOSFormattedContent(
|
|||||||
allMatches.add(match.range to "mention")
|
allMatches.add(match.range to "mention")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add standalone geohash matches (e.g., "#9q") that are not part of another word
|
||||||
|
// We use MessageSpecialParser to find exact ranges; then merge with existing ranges avoiding overlaps
|
||||||
|
val geoMatches = MessageSpecialParser.findStandaloneGeohashes(content)
|
||||||
|
for (gm in geoMatches) {
|
||||||
|
val range = gm.start until gm.endExclusive
|
||||||
|
if (!overlapsMention(range)) {
|
||||||
|
allMatches.add(range to "geohash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove generic hashtag matches that overlap with detected geohash ranges to avoid duplicate rendering
|
||||||
|
fun rangesOverlap(a: IntRange, b: IntRange): Boolean {
|
||||||
|
return a.first < b.last && a.last > b.first
|
||||||
|
}
|
||||||
|
val geoRanges = allMatches.filter { it.second == "geohash" }.map { it.first }
|
||||||
|
if (geoRanges.isNotEmpty()) {
|
||||||
|
val iterator = allMatches.listIterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val (range, type) = iterator.next()
|
||||||
|
if (type == "hashtag" && geoRanges.any { rangesOverlap(range, it) }) {
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allMatches.sortBy { it.first.first }
|
allMatches.sortBy { it.first.first }
|
||||||
|
|
||||||
var lastEnd = 0
|
var lastEnd = 0
|
||||||
@@ -327,7 +353,7 @@ private fun appendIOSFormattedContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"hashtag" -> {
|
"hashtag" -> {
|
||||||
// iOS-style: render hashtags like normal content (no special styling)
|
// Render general hashtags like normal content
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = baseColor,
|
color = baseColor,
|
||||||
fontSize = BASE_FONT_SIZE.sp,
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
@@ -342,6 +368,37 @@ private fun appendIOSFormattedContent(
|
|||||||
}
|
}
|
||||||
builder.pop()
|
builder.pop()
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
|
if (type == "geohash") {
|
||||||
|
// Style geohash in blue, underlined, and add click annotation
|
||||||
|
builder.pushStyle(SpanStyle(
|
||||||
|
color = Color(0xFF007AFF),
|
||||||
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
))
|
||||||
|
val start = builder.length
|
||||||
|
builder.append(matchText)
|
||||||
|
val end = builder.length
|
||||||
|
val geohash = matchText.removePrefix("#").lowercase()
|
||||||
|
builder.addStringAnnotation(
|
||||||
|
tag = "geohash_click",
|
||||||
|
annotation = geohash,
|
||||||
|
start = start,
|
||||||
|
end = end
|
||||||
|
)
|
||||||
|
builder.pop()
|
||||||
|
} else {
|
||||||
|
// Fallback: treat as normal text
|
||||||
|
builder.pushStyle(SpanStyle(
|
||||||
|
color = baseColor,
|
||||||
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
|
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||||
|
))
|
||||||
|
builder.append(matchText)
|
||||||
|
builder.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastEnd = range.last + 1
|
lastEnd = range.last + 1
|
||||||
|
|||||||
@@ -556,8 +556,12 @@ class ChatViewModel(
|
|||||||
// Clear all notifications
|
// Clear all notifications
|
||||||
notificationManager.clearAllNotifications()
|
notificationManager.clearAllNotifications()
|
||||||
|
|
||||||
// Clear geohash message history
|
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
||||||
nostrGeohashService.clearGeohashMessageHistory()
|
try {
|
||||||
|
nostrGeohashService.panicResetNostrAndGeohash()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
// Reset nickname
|
// Reset nickname
|
||||||
val newNickname = "anon${Random.nextInt(1000, 9999)}"
|
val newNickname = "anon${Random.nextInt(1000, 9999)}"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
@@ -199,6 +200,7 @@ private fun MessageTextWithClickableNicknames(
|
|||||||
|
|
||||||
if (!isSelf && (onNicknameClick != null || onMessageLongPress != null)) {
|
if (!isSelf && (onNicknameClick != null || onMessageLongPress != null)) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val context = LocalContext.current
|
||||||
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
Text(
|
Text(
|
||||||
text = annotatedText,
|
text = annotatedText,
|
||||||
@@ -216,6 +218,33 @@ private fun MessageTextWithClickableNicknames(
|
|||||||
val nickname = nicknameAnnotations.first().item
|
val nickname = nicknameAnnotations.first().item
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
onNicknameClick?.invoke(nickname)
|
onNicknameClick?.invoke(nickname)
|
||||||
|
return@detectTapGestures
|
||||||
|
}
|
||||||
|
// Handle geohash click to teleport
|
||||||
|
val geohashAnnotations = annotatedText.getStringAnnotations(
|
||||||
|
tag = "geohash_click",
|
||||||
|
start = offset,
|
||||||
|
end = offset
|
||||||
|
)
|
||||||
|
if (geohashAnnotations.isNotEmpty()) {
|
||||||
|
val geohash = geohashAnnotations.first().item
|
||||||
|
try {
|
||||||
|
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
|
||||||
|
context
|
||||||
|
)
|
||||||
|
// Select appropriate precision based on length
|
||||||
|
val level = when (geohash.length) {
|
||||||
|
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
||||||
|
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
||||||
|
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
||||||
|
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
||||||
|
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||||
|
}
|
||||||
|
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
|
||||||
|
locationManager.setTeleported(true)
|
||||||
|
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress = {
|
onLongPress = {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class MessageManager(private val state: ChatState) {
|
|||||||
fun addMessage(message: BitchatMessage) {
|
fun addMessage(message: BitchatMessage) {
|
||||||
val currentMessages = state.getMessagesValue().toMutableList()
|
val currentMessages = state.getMessagesValue().toMutableList()
|
||||||
currentMessages.add(message)
|
currentMessages.add(message)
|
||||||
currentMessages.sortBy { it.timestamp }
|
|
||||||
state.setMessages(currentMessages)
|
state.setMessages(currentMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ class MessageManager(private val state: ChatState) {
|
|||||||
|
|
||||||
val channelMessageList = currentChannelMessages[channel]?.toMutableList() ?: mutableListOf()
|
val channelMessageList = currentChannelMessages[channel]?.toMutableList() ?: mutableListOf()
|
||||||
channelMessageList.add(message)
|
channelMessageList.add(message)
|
||||||
channelMessageList.sortBy { it.timestamp }
|
|
||||||
currentChannelMessages[channel] = channelMessageList
|
currentChannelMessages[channel] = channelMessageList
|
||||||
state.setChannelMessages(currentChannelMessages)
|
state.setChannelMessages(currentChannelMessages)
|
||||||
|
|
||||||
@@ -83,7 +81,6 @@ class MessageManager(private val state: ChatState) {
|
|||||||
|
|
||||||
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
||||||
chatMessages.add(message)
|
chatMessages.add(message)
|
||||||
chatMessages.sortBy { it.timestamp }
|
|
||||||
currentPrivateChats[peerID] = chatMessages
|
currentPrivateChats[peerID] = chatMessages
|
||||||
state.setPrivateChats(currentPrivateChats)
|
state.setPrivateChats(currentPrivateChats)
|
||||||
|
|
||||||
@@ -103,7 +100,6 @@ class MessageManager(private val state: ChatState) {
|
|||||||
}
|
}
|
||||||
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
|
||||||
chatMessages.add(message)
|
chatMessages.add(message)
|
||||||
chatMessages.sortBy { it.timestamp }
|
|
||||||
currentPrivateChats[peerID] = chatMessages
|
currentPrivateChats[peerID] = chatMessages
|
||||||
state.setPrivateChats(currentPrivateChats)
|
state.setPrivateChats(currentPrivateChats)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilities for parsing special tokens in chat messages (geohashes, etc.).
|
||||||
|
*/
|
||||||
|
object MessageSpecialParser {
|
||||||
|
// Standalone geohash pattern like "#9q" or longer. Word boundaries enforced.
|
||||||
|
// Geohash alphabet is base32: 0123456789bcdefghjkmnpqrstuvwxyz
|
||||||
|
private val standaloneGeohashRegex = Regex("(^|[^A-Za-z0-9_#])#([0-9bcdefghjkmnpqrstuvwxyz]{2,})($|[^A-Za-z0-9_])", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
data class GeohashMatch(val start: Int, val endExclusive: Int, val geohash: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds standalone geohashes within [text]. A match is returned only when
|
||||||
|
* the '#' token is not part of another word (e.g., not in '@anon#9qk2').
|
||||||
|
*/
|
||||||
|
fun findStandaloneGeohashes(text: String): List<GeohashMatch> {
|
||||||
|
if (text.isEmpty()) return emptyList()
|
||||||
|
val matches = mutableListOf<GeohashMatch>()
|
||||||
|
var index = 0
|
||||||
|
while (index < text.length) {
|
||||||
|
val m = standaloneGeohashRegex.find(text, index) ?: break
|
||||||
|
// Adjust to only cover the geohash token starting at '#'
|
||||||
|
val fullRange = m.range
|
||||||
|
// Find the '#' within this match substring
|
||||||
|
val sub = text.substring(fullRange)
|
||||||
|
val hashPos = sub.indexOf('#')
|
||||||
|
if (hashPos >= 0) {
|
||||||
|
val tokenStart = fullRange.first + hashPos
|
||||||
|
// Consume '#' + geohash letters
|
||||||
|
var cursor = tokenStart + 1
|
||||||
|
while (cursor < text.length) {
|
||||||
|
val ch = text[cursor].lowercaseChar()
|
||||||
|
val isGeoChar = (ch in '0'..'9') || (ch in "bcdefghjkmnpqrstuvwxyz")
|
||||||
|
if (!isGeoChar) break
|
||||||
|
cursor++
|
||||||
|
}
|
||||||
|
val token = text.substring(tokenStart + 1, cursor)
|
||||||
|
if (token.length >= 2) {
|
||||||
|
matches.add(GeohashMatch(tokenStart, cursor, token.lowercase()))
|
||||||
|
}
|
||||||
|
index = cursor
|
||||||
|
} else {
|
||||||
|
index = fullRange.last + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +82,8 @@ class NotificationManager(
|
|||||||
val messageContent: String,
|
val messageContent: String,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isMention: Boolean,
|
val isMention: Boolean,
|
||||||
val isFirstMessage: Boolean
|
val isFirstMessage: Boolean,
|
||||||
|
val locationName: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -397,7 +398,8 @@ class NotificationManager(
|
|||||||
senderNickname: String,
|
senderNickname: String,
|
||||||
messageContent: String,
|
messageContent: String,
|
||||||
isMention: Boolean = false,
|
isMention: Boolean = false,
|
||||||
isFirstMessage: Boolean = false
|
isFirstMessage: Boolean = false,
|
||||||
|
locationName: String? = null
|
||||||
) {
|
) {
|
||||||
// Only show notifications if app is in background OR user is not viewing this specific geohash
|
// Only show notifications if app is in background OR user is not viewing this specific geohash
|
||||||
val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash)
|
val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash)
|
||||||
@@ -415,7 +417,8 @@ class NotificationManager(
|
|||||||
messageContent = messageContent,
|
messageContent = messageContent,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
isMention = isMention,
|
isMention = isMention,
|
||||||
isFirstMessage = isFirstMessage
|
isFirstMessage = isFirstMessage,
|
||||||
|
locationName = locationName
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add to pending notifications for this geohash
|
// Add to pending notifications for this geohash
|
||||||
@@ -453,12 +456,13 @@ class NotificationManager(
|
|||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build notification content
|
// Build notification content with location name if available
|
||||||
|
val geohashDisplay = latestNotification.locationName?.let { "$it (#$geohash)" } ?: "#$geohash"
|
||||||
val contentTitle = when {
|
val contentTitle = when {
|
||||||
mentionCount > 0 && firstMessageCount > 0 -> "Mentioned in #$geohash (+${messageCount - 1} more)"
|
mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> "Mentioned in $geohashDisplay (+${messageCount - 1} more)"
|
||||||
mentionCount > 0 -> if (mentionCount == 1) "Mentioned in #$geohash" else "$mentionCount mentions in #$geohash"
|
mentionCount > 0 -> if (mentionCount == 1) "Mentioned in $geohashDisplay" else "$mentionCount mentions in $geohashDisplay"
|
||||||
firstMessageCount > 0 -> "New activity in #$geohash"
|
firstMessageCount > 0 -> "New activity in $geohashDisplay"
|
||||||
else -> "Messages in #$geohash"
|
else -> "Messages in $geohashDisplay"
|
||||||
}
|
}
|
||||||
|
|
||||||
val contentText = when {
|
val contentText = when {
|
||||||
@@ -564,10 +568,12 @@ class NotificationManager(
|
|||||||
pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) ->
|
pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) ->
|
||||||
val mentionCount = notifications.count { it.isMention }
|
val mentionCount = notifications.count { it.isMention }
|
||||||
val messageCount = notifications.size
|
val messageCount = notifications.size
|
||||||
|
val latestNotification = notifications.last()
|
||||||
|
val geohashDisplay = latestNotification.locationName?.let { "$it (#$geohash)" } ?: "#$geohash"
|
||||||
val line = when {
|
val line = when {
|
||||||
mentionCount > 0 -> "#$geohash: $mentionCount mentions (+${messageCount - mentionCount} more)"
|
mentionCount > 0 -> "$geohashDisplay: $mentionCount mentions (+${messageCount - mentionCount} more)"
|
||||||
messageCount == 1 -> "#$geohash: 1 message"
|
messageCount == 1 -> "$geohashDisplay: 1 message"
|
||||||
else -> "#$geohash: $messageCount messages"
|
else -> "$geohashDisplay: $messageCount messages"
|
||||||
}
|
}
|
||||||
style.addLine(line)
|
style.addLine(line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,7 +501,6 @@ class PrivateChatManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (didMerge) {
|
if (didMerge) {
|
||||||
targetList.sortBy { it.timestamp }
|
|
||||||
currentChats[targetPeerID] = targetList
|
currentChats[targetPeerID] = targetList
|
||||||
state.setPrivateChats(currentChats)
|
state.setPrivateChats(currentChats)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user