Compare commits

..
Author SHA1 Message Date
jack ebfceb21bb Fix duplicate messages after channel switch\n\n- Dedup on per-geohash append (skip if ID exists)\n- Dedup and sort timeline by timestamp when switching into a geohash\n- Keep content/layout unchanged; add lightweight debug logs for empty rows 2025-08-28 14:36:29 +02:00
jack b2685e5156 Screenshot privacy: warn on location sheet; gate screenshot broadcasts to chat only\n\n- Track sheet presentation in ChatViewModel\n- Show privacy alert when screenshot taken on location channels\n- Ignore screenshots for App Info and Location sheet for broadcast\n- ContentView wires sheet onAppear/onDisappear and presents alert\n- Fix location sheet subtitle wrapping: render as single Text to avoid orphaned bullets 2025-08-28 14:03:05 +02:00
jack 3e5043f875 Harden text formatting against Unicode range mismatches
- Use NSString.length for all NSRange regex scans
- Guard string slicing when lastEnd > match.lowerBound
- Advance lastEnd safely to avoid backtracking overlaps
- Apply to formatMessageContent, formatMessage, formatMessageAsText, parseMentions
2025-08-28 09:22:34 +01:00
jack 11cd5527ed Fix crash in formatMessageAsText: use NSString length for NSRanges and guard slicing before matches to avoid invalid ranges with multi-byte content 2025-08-28 09:18:11 +01:00
cf1bfdac6b Geohash Bookmarks + Rising‑Edge Notifications + Friendly Names (#538)
* Add geohash bookmarks: persistence, sampling integration, and UI\n\n- Add GeohashBookmarksStore with UserDefaults persistence and toggle API\n- Sample union of regional + bookmarked geohashes for activity notifications\n- LocationChannelsSheet: bookmark icons on nearby rows and a Bookmarked section\n- Header toolbar: toggle bookmark for current geohash\n- Tests: GeohashBookmarksStoreTests for normalization and persistence\n\nRationale: Bookmarked geohashes are always sampled for new activity notifications and quickly selectable from the sheet.

* Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive)

* Geohash notifications: ensure triggering message appears\n\n- On zero→alive sampling, pre-populate geoTimelines[gh] with the triggering message\n- Dedup on per-geohash timeline and UI messages by message ID to avoid duplicates after subscribe\n- Keeps participant update, block/self checks, and cooldown intact

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 09:10:15 +01:00
3 changed files with 115 additions and 56 deletions
+92 -39
View File
@@ -382,6 +382,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var showBluetoothAlert = false @Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = "" @Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown @Published var bluetoothState: CBManagerState = .unknown
// Presentation state for privacy gating
@Published var isLocationChannelsSheetPresented: Bool = false
@Published var isAppInfoPresented: Bool = false
@Published var showScreenshotPrivacyWarning: Bool = false
// Messages are naturally ephemeral - no persistent storage // Messages are naturally ephemeral - no persistent storage
// Persist mesh public timeline across channel switches // Persist mesh public timeline across channel switches
@@ -1407,6 +1412,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
switch channel { switch channel {
case .mesh: case .mesh:
messages = meshTimeline messages = meshTimeline
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyMesh > 0 {
SecureLogger.log("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: SecureLogger.session, level: .debug)
}
stopGeoParticipantsTimer() stopGeoParticipantsTimer()
geohashPeople = [] geohashPeople = []
teleportedGeo.removeAll() teleportedGeo.removeAll()
@@ -1414,13 +1424,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Sanitize existing timeline (filter any prior empty-content entries) // Sanitize existing timeline (filter any prior empty-content entries)
var arr = geoTimelines[ch.geohash] ?? [] var arr = geoTimelines[ch.geohash] ?? []
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
// Ensure chronological order when returning to a geohash // Deduplicate by ID while preserving order (from oldest to newest)
if arr.count > 1 { if arr.count > 1 {
arr.sort { $0.timestamp < $1.timestamp } var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
arr = dedup
} }
// Persist the cleaned/sorted timeline for this geohash // Persist the cleaned/sorted timeline for this geohash
geoTimelines[ch.geohash] = arr geoTimelines[ch.geohash] = arr
messages = arr messages = arr
// Debug: log if any empty messages are present post-sanitize
let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyGeo > 0 {
SecureLogger.log("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: SecureLogger.session, level: .debug)
}
} }
// Unsubscribe previous // Unsubscribe previous
if let sub = geoSubscriptionID { if let sub = geoSubscriptionID {
@@ -2588,6 +2611,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
@objc private func userDidTakeScreenshot() { @objc private func userDidTakeScreenshot() {
// Respect privacy: do not broadcast screenshots taken from non-chat sheets
if isLocationChannelsSheetPresented {
// Show a warning about sharing location screenshots publicly
showScreenshotPrivacyWarning = true
return
}
if isAppInfoPresented {
// Silently ignore screenshots of app info
return
}
// Send screenshot notification based on current context // Send screenshot notification based on current context
let screenshotMessage = "* \(nickname) took a screenshot *" let screenshotMessage = "* \(nickname) took a screenshot *"
@@ -2607,7 +2641,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
// Show local notification immediately as system message // Show local notification immediately as system message (only in chat)
let localNotification = BitchatMessage( let localNotification = BitchatMessage(
sender: "system", sender: "system",
content: "you took a screenshot", content: "you took a screenshot",
@@ -2658,7 +2692,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Show local notification immediately as system message // Show local notification immediately as system message (only in chat)
let localNotification = BitchatMessage( let localNotification = BitchatMessage(
sender: "system", sender: "system",
content: "you took a screenshot", content: "you took a screenshot",
@@ -3043,8 +3077,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] let nsContent = contentText as NSString
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] let nsLen = nsContent.length
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
// Combine and sort all matches // Combine and sort all matches
var allMatches: [(range: NSRange, type: String)] = [] var allMatches: [(range: NSRange, type: String)] = []
@@ -3061,12 +3097,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (matchRange, matchType) in allMatches { for (matchRange, matchType) in allMatches {
// Add text before the match // Add text before the match
if let range = Range(matchRange, in: contentText) { if let range = Range(matchRange, in: contentText) {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound]) if lastEndIndex < range.lowerBound {
if !beforeText.isEmpty { let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
var normalStyle = AttributeContainer() if !beforeText.isEmpty {
normalStyle.font = .system(size: 14, design: .monospaced) var normalStyle = AttributeContainer()
normalStyle.foregroundColor = isDark ? Color.white : Color.black normalStyle.font = .system(size: 14, design: .monospaced)
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle)) normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
} }
// Add the match with appropriate styling // Add the match with appropriate styling
@@ -3084,7 +3122,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle)) processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
lastEndIndex = range.upperBound if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
} }
} }
@@ -3161,9 +3199,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// For extremely long content, render as plain text to avoid heavy regex/layout work, // For extremely long content, render as plain text to avoid heavy regex/layout work,
// unless the content includes Cashu tokens we want to chip-render below // unless the content includes Cashu tokens we want to chip-render below
// Compute NSString-backed length for regex/nsrange correctness with multi-byte characters
let nsContent = content as NSString
let nsLen = nsContent.length
let containsCashuEarly: Bool = { let containsCashuEarly: Bool = {
let rx = Regexes.quickCashuPresence let rx = Regexes.quickCashuPresence
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0 return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
}() }()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer() var plainStyle = AttributeContainer()
@@ -3181,8 +3222,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let lnurlRegex = Regexes.lnurl let lnurlRegex = Regexes.lnurl
let lightningSchemeRegex = Regexes.lightningScheme let lightningSchemeRegex = Regexes.lightningScheme
let detector = Regexes.linkDetector let detector = Regexes.linkDetector
let nsLen = content.count
let hasMentionsHint = content.contains("@") let hasMentionsHint = content.contains("@")
let hasHashtagsHint = content.contains("#") let hasHashtagsHint = content.contains("#")
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
@@ -3262,17 +3301,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (range, type) in allMatches { for (range, type) in allMatches {
// Add text before match // Add text before match
if let nsRange = Range(range, in: content) { if let nsRange = Range(range, in: content) {
let beforeText = String(content[lastEnd..<nsRange.lowerBound]) if lastEnd < nsRange.lowerBound {
if !beforeText.isEmpty { let beforeText = String(content[lastEnd..<nsRange.lowerBound])
var beforeStyle = AttributeContainer() if !beforeText.isEmpty {
beforeStyle.foregroundColor = baseColor var beforeStyle = AttributeContainer()
beforeStyle.font = isSelf beforeStyle.foregroundColor = baseColor
? .system(size: 14, weight: .bold, design: .monospaced) beforeStyle.font = isSelf
: .system(size: 14, design: .monospaced) ? .system(size: 14, weight: .bold, design: .monospaced)
if isMentioned { : .system(size: 14, design: .monospaced)
beforeStyle.font = beforeStyle.font?.bold() if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
} }
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
} }
// Add styled match // Add styled match
@@ -3380,7 +3421,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(AttributedString(matchText).mergingAttributes(matchStyle)) result.append(AttributedString(matchText).mergingAttributes(matchStyle))
} }
} }
lastEnd = nsRange.upperBound // Advance lastEnd safely in case of overlaps
if lastEnd < nsRange.upperBound {
lastEnd = nsRange.upperBound
}
} }
} }
@@ -3478,19 +3522,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Regular expression to find @mentions // Regular expression to find @mentions
let pattern = "@([\\p{L}0-9_]+)" let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: []) let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? [] let nsContent = contentText as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var lastEndIndex = contentText.startIndex var lastEndIndex = contentText.startIndex
for match in matches { for match in matches {
// Add text before the mention // Add text before the mention
if let range = Range(match.range(at: 0), in: contentText) { if let range = Range(match.range(at: 0), in: contentText) {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound]) if lastEndIndex < range.lowerBound {
if !beforeText.isEmpty { let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
var normalStyle = AttributeContainer() if !beforeText.isEmpty {
normalStyle.font = .system(size: 14, design: .monospaced) var normalStyle = AttributeContainer()
normalStyle.foregroundColor = isDark ? Color.white : Color.black normalStyle.font = .system(size: 14, design: .monospaced)
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle)) normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
} }
// Add the mention with highlight // Add the mention with highlight
@@ -3500,7 +3548,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
mentionStyle.foregroundColor = Color.orange mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle)) processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
lastEndIndex = range.upperBound if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
} }
} }
@@ -4698,7 +4746,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames // Allow optional disambiguation suffix '#abcd' for duplicate nicknames
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
let regex = try? NSRegularExpression(pattern: pattern, options: []) let regex = try? NSRegularExpression(pattern: pattern, options: [])
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] let nsContent = content as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var mentions: [String] = [] var mentions: [String] = []
let peerNicknames = meshService.getPeerNicknames() let peerNicknames = meshService.getPeerNicknames()
@@ -5765,9 +5815,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isGeo && finalMessage.sender != "system" { if isGeo && finalMessage.sender != "system" {
if let gh = currentGeohash { if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? [] var arr = geoTimelines[gh] ?? []
arr.append(finalMessage) // Dedup by message ID before appending to per-geohash timeline
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } if !arr.contains(where: { $0.id == finalMessage.id }) {
geoTimelines[gh] = arr arr.append(finalMessage)
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr
}
} }
} }
+13 -2
View File
@@ -165,6 +165,8 @@ struct ContentView: View {
} }
.sheet(isPresented: $showAppInfo) { .sheet(isPresented: $showAppInfo) {
AppInfoView() AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil }, get: { viewModel.showingFingerprintFor != nil },
@@ -279,8 +281,10 @@ struct ContentView: View {
} }
}() }()
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
// Filter out empty/whitespace-only messages to avoid blank rows
ForEach(items, id: \.uiID) { item in let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
ForEach(filteredItems, id: \.uiID) { item in
let message = item.message let message = item.message
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned // Check if current user is mentioned
@@ -1182,6 +1186,13 @@ struct ContentView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {}
} message: {
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
} }
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
+10 -15
View File
@@ -304,22 +304,17 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
HStack(spacing: 0) { let subtitleFull: String = {
Text(subtitlePrefix) if let name = subtitleName, !name.isEmpty {
.font(.system(size: 12, design: .monospaced)) return subtitlePrefix + "" + name
.foregroundColor(.secondary)
if let name = subtitleName {
Text("")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Text(name)
.font(.system(size: 12, design: .monospaced))
.fontWeight(subtitleNameBold ? .bold : .regular)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
} }
return subtitlePrefix
}()
Text(subtitleFull)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
} }
Spacer() Spacer()
if isSelected { if isSelected {