|
|
|
@@ -382,6 +382,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
@Published var showBluetoothAlert = false
|
|
|
|
|
@Published var bluetoothAlertMessage = ""
|
|
|
|
|
@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
|
|
|
|
|
// Persist mesh public timeline across channel switches
|
|
|
|
@@ -1407,6 +1412,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
switch channel {
|
|
|
|
|
case .mesh:
|
|
|
|
|
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()
|
|
|
|
|
geohashPeople = []
|
|
|
|
|
teleportedGeo.removeAll()
|
|
|
|
@@ -1414,13 +1424,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
// Sanitize existing timeline (filter any prior empty-content entries)
|
|
|
|
|
var arr = geoTimelines[ch.geohash] ?? []
|
|
|
|
|
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 {
|
|
|
|
|
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
|
|
|
|
|
geoTimelines[ch.geohash] = 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
|
|
|
|
|
if let sub = geoSubscriptionID {
|
|
|
|
@@ -2588,6 +2611,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
@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
|
|
|
|
|
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(
|
|
|
|
|
sender: "system",
|
|
|
|
|
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(
|
|
|
|
|
sender: "system",
|
|
|
|
|
content: "you took a screenshot",
|
|
|
|
@@ -3043,8 +3077,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
|
|
|
|
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
|
|
|
|
|
|
|
|
|
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
|
|
|
|
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
|
|
|
|
let nsContent = contentText as NSString
|
|
|
|
|
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
|
|
|
|
|
var allMatches: [(range: NSRange, type: String)] = []
|
|
|
|
@@ -3061,12 +3097,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
for (matchRange, matchType) in allMatches {
|
|
|
|
|
// Add text before the match
|
|
|
|
|
if let range = Range(matchRange, in: contentText) {
|
|
|
|
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
|
|
|
|
if !beforeText.isEmpty {
|
|
|
|
|
var normalStyle = AttributeContainer()
|
|
|
|
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
|
|
|
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
|
|
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
|
|
|
if lastEndIndex < range.lowerBound {
|
|
|
|
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
|
|
|
|
if !beforeText.isEmpty {
|
|
|
|
|
var normalStyle = AttributeContainer()
|
|
|
|
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
|
|
|
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
|
|
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add the match with appropriate styling
|
|
|
|
@@ -3084,7 +3122,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
// 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 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 {
|
|
|
|
|
var plainStyle = AttributeContainer()
|
|
|
|
@@ -3181,8 +3222,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
let lnurlRegex = Regexes.lnurl
|
|
|
|
|
let lightningSchemeRegex = Regexes.lightningScheme
|
|
|
|
|
let detector = Regexes.linkDetector
|
|
|
|
|
|
|
|
|
|
let nsLen = content.count
|
|
|
|
|
let hasMentionsHint = content.contains("@")
|
|
|
|
|
let hasHashtagsHint = content.contains("#")
|
|
|
|
|
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
|
|
|
@@ -3262,17 +3301,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
for (range, type) in allMatches {
|
|
|
|
|
// Add text before match
|
|
|
|
|
if let nsRange = Range(range, in: content) {
|
|
|
|
|
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
|
|
|
|
if !beforeText.isEmpty {
|
|
|
|
|
var beforeStyle = AttributeContainer()
|
|
|
|
|
beforeStyle.foregroundColor = baseColor
|
|
|
|
|
beforeStyle.font = isSelf
|
|
|
|
|
? .system(size: 14, weight: .bold, design: .monospaced)
|
|
|
|
|
: .system(size: 14, design: .monospaced)
|
|
|
|
|
if isMentioned {
|
|
|
|
|
beforeStyle.font = beforeStyle.font?.bold()
|
|
|
|
|
if lastEnd < nsRange.lowerBound {
|
|
|
|
|
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
|
|
|
|
if !beforeText.isEmpty {
|
|
|
|
|
var beforeStyle = AttributeContainer()
|
|
|
|
|
beforeStyle.foregroundColor = baseColor
|
|
|
|
|
beforeStyle.font = isSelf
|
|
|
|
|
? .system(size: 14, weight: .bold, design: .monospaced)
|
|
|
|
|
: .system(size: 14, design: .monospaced)
|
|
|
|
|
if isMentioned {
|
|
|
|
|
beforeStyle.font = beforeStyle.font?.bold()
|
|
|
|
|
}
|
|
|
|
|
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
|
|
|
|
}
|
|
|
|
|
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add styled match
|
|
|
|
@@ -3380,7 +3421,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
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
|
|
|
|
|
let pattern = "@([\\p{L}0-9_]+)"
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
for match in matches {
|
|
|
|
|
// Add text before the mention
|
|
|
|
|
if let range = Range(match.range(at: 0), in: contentText) {
|
|
|
|
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
|
|
|
|
if !beforeText.isEmpty {
|
|
|
|
|
var normalStyle = AttributeContainer()
|
|
|
|
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
|
|
|
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
|
|
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
|
|
|
if lastEndIndex < range.lowerBound {
|
|
|
|
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
|
|
|
|
if !beforeText.isEmpty {
|
|
|
|
|
var normalStyle = AttributeContainer()
|
|
|
|
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
|
|
|
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
|
|
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add the mention with highlight
|
|
|
|
@@ -3500,7 +3548,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
mentionStyle.foregroundColor = Color.orange
|
|
|
|
|
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
|
|
|
|
|
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
|
|
|
|
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] = []
|
|
|
|
|
let peerNicknames = meshService.getPeerNicknames()
|
|
|
|
@@ -5765,9 +5815,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|
|
|
|
if isGeo && finalMessage.sender != "system" {
|
|
|
|
|
if let gh = currentGeohash {
|
|
|
|
|
var arr = geoTimelines[gh] ?? []
|
|
|
|
|
arr.append(finalMessage)
|
|
|
|
|
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
|
|
|
|
geoTimelines[gh] = arr
|
|
|
|
|
// Dedup by message ID before appending to per-geohash timeline
|
|
|
|
|
if !arr.contains(where: { $0.id == finalMessage.id }) {
|
|
|
|
|
arr.append(finalMessage)
|
|
|
|
|
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
|
|
|
|
geoTimelines[gh] = arr
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|