bitchat-android/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt
Andrés Villagrán 7afab46dc0 feat(nostr): Add mesh-to-relay gateway for #me messages
Core changes:
- Filter Nostr packets in MessageHandler to route to gateway instead of displaying in public chat
- Add event ID deduplication cache to prevent multiple online devices publishing same event
- Add PoW requirement notification for offline #me messages
- Fix signature invalidation by signing AFTER PoW mining completes

New files:
- NostrProfileManager: Manages #me channel message publishing with PublishResult sealed class
- NostrAccountSheet: UI for Nostr account management
- NostrMeshGatewayTest: Unit tests for serializer

UI/UX improvements:
- Enhanced image viewer with pinch-to-zoom and pan gestures
- Improved message components styling
- Location channels sheet updates
- About sheet enhancements
2026-01-15 03:34:09 -03:00

150 lines
5.9 KiB
Kotlin

package com.bitchat.android.ui
import androidx.compose.ui.text.AnnotatedString
import android.util.Patterns
/**
* 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)
// Image URL extensions (case insensitive)
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "avif", "bmp", "svg")
data class GeohashMatch(val start: Int, val endExclusive: Int, val geohash: String)
data class UrlMatch(val start: Int, val endExclusive: Int, val url: String)
data class ImageUrlMatch(val url: String, val start: Int, val endExclusive: Int)
/**
* Finds image URLs in text. Returns URLs ending with common image extensions.
* Also handles URLs with query params after the extension (e.g., image.jpg?size=large)
*/
fun findImageUrls(text: String): List<ImageUrlMatch> {
val urls = findUrls(text)
return urls.mapNotNull { urlMatch ->
val url = urlMatch.url
// Normalize URL for checking (add https:// if missing)
val normalizedUrl = if (url.startsWith("http://", ignoreCase = true) ||
url.startsWith("https://", ignoreCase = true)) {
url
} else {
"https://$url"
}
// Extract path without query params
val pathPart = normalizedUrl.substringBefore('?').substringBefore('#')
val extension = pathPart.substringAfterLast('.', "").lowercase()
if (extension in IMAGE_EXTENSIONS) {
ImageUrlMatch(normalizedUrl, urlMatch.start, urlMatch.endExclusive)
} else {
null
}
}
}
/**
* Extracts image URLs from message content, returning list of full URLs.
*/
fun extractImageUrls(content: String): List<String> {
return findImageUrls(content).map { it.url }
}
/**
* 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
}
/**
* Detect URLs in text. Supports http(s) schemes, www.* domains, and bare domains with TLDs.
* Trailing punctuation like '.', ',', ')', '!' is trimmed from the match.
*/
fun findUrls(text: String): List<UrlMatch> {
if (text.isEmpty()) return emptyList()
val results = mutableListOf<UrlMatch>()
// 1) Use Android's WEB_URL for robust detection of http(s) and www.*
val webUrl = Patterns.WEB_URL
val matcher = webUrl.matcher(text)
while (matcher.find()) {
var start = matcher.start()
var endExclusive = matcher.end()
var token = text.substring(start, endExclusive)
// Trim trailing punctuation
while (token.isNotEmpty() && token.last() in setOf('.', ',', ';', ':', '!', '?', '\'', '"')) {
endExclusive -= 1
token = text.substring(start, endExclusive)
}
results.add(UrlMatch(start, endExclusive, token))
}
// 2) Bare-domain fallback for things WEB_URL can miss (e.g., google.com)
val bare = Regex("(?<!@)(?<![A-Za-z0-9_-])([A-Za-z0-9-]+\\.[A-Za-z]{2,}(?:/[A-Za-z0-9@:%._+~#=/?&!$'()*,-]*)?)")
for (m in bare.findAll(text)) {
val start = m.range.first
var endExclusive = m.range.last + 1
var token = text.substring(start, endExclusive)
// Exclude if overlaps any already-found match
val overlapsExisting = results.any { start < it.endExclusive && endExclusive > it.start }
if (overlapsExisting) continue
// Skip emails
if (token.contains('@')) continue
// Trim trailing punctuation
while (token.isNotEmpty() && token.last() in setOf('.', ',', ';', ':', '!', '?', '\'', '"')) {
endExclusive -= 1
token = text.substring(start, endExclusive)
}
// Require at least one dot
if (!token.contains('.')) continue
results.add(UrlMatch(start, endExclusive, token))
}
// Sort by start index
results.sortBy { it.start }
return results
}
}