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:
callebtc
2025-08-28 17:47:47 +02:00
committed by GitHub
parent 3ea2aed9a4
commit 550520795f
12 changed files with 393 additions and 34 deletions
@@ -5,6 +5,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.BluetoothMeshService
@@ -80,11 +81,11 @@ fun formatMessageAsAnnotatedString(
builder.append(baseName)
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) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = message.sender, // Store full sender name with hash
annotation = (message.originalSender ?: message.sender),
start = nicknameStart,
end = nicknameEnd
)
@@ -257,6 +258,31 @@ private fun appendIOSFormattedContent(
for (match in mentionMatches) {
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 }
@@ -327,7 +353,7 @@ private fun appendIOSFormattedContent(
}
}
"hashtag" -> {
// iOS-style: render hashtags like normal content (no special styling)
// Render general hashtags like normal content
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
@@ -342,6 +368,37 @@ private fun appendIOSFormattedContent(
}
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
@@ -556,8 +556,12 @@ class ChatViewModel(
// Clear all notifications
notificationManager.clearAllNotifications()
// Clear geohash message history
nostrGeohashService.clearGeohashMessageHistory()
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
try {
nostrGeohashService.panicResetNostrAndGeohash()
} catch (e: Exception) {
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
}
// Reset nickname
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import com.bitchat.android.model.BitchatMessage
@@ -199,6 +200,7 @@ private fun MessageTextWithClickableNicknames(
if (!isSelf && (onNicknameClick != null || onMessageLongPress != null)) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedText,
@@ -216,6 +218,33 @@ private fun MessageTextWithClickableNicknames(
val nickname = nicknameAnnotations.first().item
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
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 = {
@@ -21,7 +21,6 @@ class MessageManager(private val state: ChatState) {
fun addMessage(message: BitchatMessage) {
val currentMessages = state.getMessagesValue().toMutableList()
currentMessages.add(message)
currentMessages.sortBy { it.timestamp }
state.setMessages(currentMessages)
}
@@ -39,7 +38,6 @@ class MessageManager(private val state: ChatState) {
val channelMessageList = currentChannelMessages[channel]?.toMutableList() ?: mutableListOf()
channelMessageList.add(message)
channelMessageList.sortBy { it.timestamp }
currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages)
@@ -83,7 +81,6 @@ class MessageManager(private val state: ChatState) {
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
chatMessages.sortBy { it.timestamp }
currentPrivateChats[peerID] = chatMessages
state.setPrivateChats(currentPrivateChats)
@@ -103,7 +100,6 @@ class MessageManager(private val state: ChatState) {
}
val chatMessages = currentPrivateChats[peerID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
chatMessages.sortBy { it.timestamp }
currentPrivateChats[peerID] = chatMessages
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 timestamp: Long,
val isMention: Boolean,
val isFirstMessage: Boolean
val isFirstMessage: Boolean,
val locationName: String? = null
)
init {
@@ -397,7 +398,8 @@ class NotificationManager(
senderNickname: String,
messageContent: String,
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
val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash)
@@ -415,7 +417,8 @@ class NotificationManager(
messageContent = messageContent,
timestamp = System.currentTimeMillis(),
isMention = isMention,
isFirstMessage = isFirstMessage
isFirstMessage = isFirstMessage,
locationName = locationName
)
// Add to pending notifications for this geohash
@@ -453,12 +456,13 @@ class NotificationManager(
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 {
mentionCount > 0 && firstMessageCount > 0 -> "Mentioned in #$geohash (+${messageCount - 1} more)"
mentionCount > 0 -> if (mentionCount == 1) "Mentioned in #$geohash" else "$mentionCount mentions in #$geohash"
firstMessageCount > 0 -> "New activity in #$geohash"
else -> "Messages in #$geohash"
mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> "Mentioned in $geohashDisplay (+${messageCount - 1} more)"
mentionCount > 0 -> if (mentionCount == 1) "Mentioned in $geohashDisplay" else "$mentionCount mentions in $geohashDisplay"
firstMessageCount > 0 -> "New activity in $geohashDisplay"
else -> "Messages in $geohashDisplay"
}
val contentText = when {
@@ -564,10 +568,12 @@ class NotificationManager(
pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) ->
val mentionCount = notifications.count { it.isMention }
val messageCount = notifications.size
val latestNotification = notifications.last()
val geohashDisplay = latestNotification.locationName?.let { "$it (#$geohash)" } ?: "#$geohash"
val line = when {
mentionCount > 0 -> "#$geohash: $mentionCount mentions (+${messageCount - mentionCount} more)"
messageCount == 1 -> "#$geohash: 1 message"
else -> "#$geohash: $messageCount messages"
mentionCount > 0 -> "$geohashDisplay: $mentionCount mentions (+${messageCount - mentionCount} more)"
messageCount == 1 -> "$geohashDisplay: 1 message"
else -> "$geohashDisplay: $messageCount messages"
}
style.addLine(line)
}
@@ -501,7 +501,6 @@ class PrivateChatManager(
}
if (didMerge) {
targetList.sortBy { it.timestamp }
currentChats[targetPeerID] = targetList
state.setPrivateChats(currentChats)