mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 09:05:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b363b7062 | ||
|
|
7d672f2d69 | ||
|
|
d35d3f9612 | ||
|
|
9e79b18dcb |
@@ -44,12 +44,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability
|
||||||
// Pending sends held only for relays that are not yet connected.
|
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = []
|
||||||
private struct PendingSend {
|
|
||||||
var event: NostrEvent
|
|
||||||
var pendingRelays: Set<String>
|
|
||||||
}
|
|
||||||
private var messageQueue: [PendingSend] = []
|
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
@@ -100,57 +95,16 @@ class NostrRelayManager: ObservableObject {
|
|||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||||
ensureConnections(to: targetRelays)
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
// Attempt immediate send to relays with active connections; queue the rest
|
// Add to queue for reliability
|
||||||
var stillPending = Set<String>()
|
messageQueueLock.lock()
|
||||||
|
messageQueue.append((event, targetRelays))
|
||||||
|
messageQueueLock.unlock()
|
||||||
|
|
||||||
|
// Attempt immediate send
|
||||||
for relayUrl in targetRelays {
|
for relayUrl in targetRelays {
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connections[relayUrl] {
|
||||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||||
} else {
|
|
||||||
stillPending.insert(relayUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !stillPending.isEmpty {
|
|
||||||
messageQueueLock.lock()
|
|
||||||
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
|
|
||||||
messageQueueLock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to flush any queued messages for relays that are now connected.
|
|
||||||
private func flushMessageQueue(for relayUrl: String? = nil) {
|
|
||||||
messageQueueLock.lock()
|
|
||||||
defer { messageQueueLock.unlock() }
|
|
||||||
guard !messageQueue.isEmpty else { return }
|
|
||||||
if let target = relayUrl {
|
|
||||||
// Flush only for a specific relay
|
|
||||||
for i in (0..<messageQueue.count).reversed() {
|
|
||||||
var item = messageQueue[i]
|
|
||||||
if item.pendingRelays.contains(target), let conn = connections[target] {
|
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
|
||||||
item.pendingRelays.remove(target)
|
|
||||||
if item.pendingRelays.isEmpty {
|
|
||||||
messageQueue.remove(at: i)
|
|
||||||
} else {
|
|
||||||
messageQueue[i] = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Flush for any relays that now have connections
|
|
||||||
for i in (0..<messageQueue.count).reversed() {
|
|
||||||
var item = messageQueue[i]
|
|
||||||
for url in item.pendingRelays {
|
|
||||||
if let conn = connections[url] {
|
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
|
||||||
item.pendingRelays.remove(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if item.pendingRelays.isEmpty {
|
|
||||||
messageQueue.remove(at: i)
|
|
||||||
} else {
|
|
||||||
messageQueue[i] = item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,10 +389,6 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
// If we just connected to this relay, flush any queued sends targeting it
|
|
||||||
if isConnected {
|
|
||||||
flushMessageQueue(for: url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateConnectionStatus() {
|
private func updateConnectionStatus() {
|
||||||
|
|||||||
@@ -223,30 +223,7 @@ final class BLEService: NSObject {
|
|||||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||||
} else {
|
} else {
|
||||||
self.collectionsQueue.async(flags: .barrier) {
|
self.collectionsQueue.async(flags: .barrier) {
|
||||||
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
self.pendingPeripheralWrites[uuid, default: []].append(data)
|
||||||
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
|
|
||||||
let newSize = data.count
|
|
||||||
// If single chunk exceeds cap, drop it immediately
|
|
||||||
if newSize > capBytes {
|
|
||||||
SecureLogger.log("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
} else {
|
|
||||||
// Append and trim from the front to respect cap
|
|
||||||
var total = queue.reduce(0) { $0 + $1.count }
|
|
||||||
queue.append(data)
|
|
||||||
total += newSize
|
|
||||||
if total > capBytes {
|
|
||||||
var removedBytes = 0
|
|
||||||
while total > capBytes && !queue.isEmpty {
|
|
||||||
let removed = queue.removeFirst()
|
|
||||||
removedBytes += removed.count
|
|
||||||
total -= removed.count
|
|
||||||
}
|
|
||||||
SecureLogger.log("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
}
|
|
||||||
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,19 +89,10 @@ final class MessageRouter {
|
|||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
private func canSendViaNostr(peerID: String) -> Bool {
|
private func canSendViaNostr(peerID: String) -> Bool {
|
||||||
// Two forms are supported:
|
guard let noiseKey = Data(hexString: peerID) else { return false }
|
||||||
// - 64-hex Noise public key (32 bytes)
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
fav.peerNostrPublicKey != nil {
|
||||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
return true
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
|
||||||
fav.peerNostrPublicKey != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else if peerID.count == 16 {
|
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
|
||||||
fav.peerNostrPublicKey != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -110,7 +101,6 @@ final class MessageRouter {
|
|||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
|
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
@@ -122,19 +112,14 @@ final class MessageRouter {
|
|||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Keep unsent items queued
|
continue
|
||||||
remaining.append((content, nickname, messageID))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Persist only items we could not send
|
// Remove all flushed items (remaining ones, if any, will be re-queued on next call)
|
||||||
if remaining.isEmpty {
|
outbox[peerID]?.removeAll()
|
||||||
outbox.removeValue(forKey: peerID)
|
|
||||||
} else {
|
|
||||||
outbox[peerID] = remaining
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
for key in outbox.keys { flushOutbox(for: key) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
private var peerIndex: [String: BitchatPeer] = [:]
|
private var peerIndex: [String: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
weak var messageRouter: MessageRouter?
|
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
@@ -331,13 +330,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
|
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Send favorite notification to the peer via router (mesh or Nostr)
|
// Send favorite notification to the peer
|
||||||
if let router = messageRouter {
|
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||||
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
|
||||||
} else {
|
|
||||||
// Fallback to mesh-only if router not yet wired
|
|
||||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force update of peers to reflect the change
|
// Force update of peers to reflect the change
|
||||||
updatePeers()
|
updatePeers()
|
||||||
|
|||||||
@@ -382,11 +382,6 @@ 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
|
||||||
@@ -492,8 +487,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||||
// Route receipts from PrivateChatManager through MessageRouter
|
// Route receipts from PrivateChatManager through MessageRouter
|
||||||
self.privateChatManager.messageRouter = self.messageRouter
|
self.privateChatManager.messageRouter = self.messageRouter
|
||||||
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
|
|
||||||
self.unifiedPeerService.messageRouter = self.messageRouter
|
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
|
|
||||||
// Wire up dependencies
|
// Wire up dependencies
|
||||||
@@ -1414,11 +1407,6 @@ 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()
|
||||||
@@ -1426,26 +1414,13 @@ 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 }
|
||||||
// Deduplicate by ID while preserving order (from oldest to newest)
|
// Ensure chronological order when returning to a geohash
|
||||||
if arr.count > 1 {
|
if arr.count > 1 {
|
||||||
var seen = Set<String>()
|
arr.sort { $0.timestamp < $1.timestamp }
|
||||||
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 {
|
||||||
@@ -2613,17 +2588,6 @@ 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 *"
|
||||||
|
|
||||||
@@ -2643,7 +2607,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show local notification immediately as system message (only in chat)
|
// Show local notification immediately as system message
|
||||||
let localNotification = BitchatMessage(
|
let localNotification = BitchatMessage(
|
||||||
sender: "system",
|
sender: "system",
|
||||||
content: "you took a screenshot",
|
content: "you took a screenshot",
|
||||||
@@ -2694,7 +2658,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Show local notification immediately as system message (only in chat)
|
// Show local notification immediately as system message
|
||||||
let localNotification = BitchatMessage(
|
let localNotification = BitchatMessage(
|
||||||
sender: "system",
|
sender: "system",
|
||||||
content: "you took a screenshot",
|
content: "you took a screenshot",
|
||||||
@@ -3079,10 +3043,8 @@ 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 nsContent = contentText as NSString
|
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||||
let nsLen = nsContent.length
|
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||||
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)] = []
|
||||||
@@ -3099,14 +3061,12 @@ 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) {
|
||||||
if lastEndIndex < range.lowerBound {
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
if !beforeText.isEmpty {
|
||||||
if !beforeText.isEmpty {
|
var normalStyle = AttributeContainer()
|
||||||
var normalStyle = AttributeContainer()
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the match with appropriate styling
|
// Add the match with appropriate styling
|
||||||
@@ -3124,7 +3084,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||||
|
|
||||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
lastEndIndex = range.upperBound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3201,12 +3161,9 @@ 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: nsLen)) > 0
|
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 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()
|
||||||
@@ -3224,6 +3181,8 @@ 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")
|
||||||
@@ -3303,19 +3262,17 @@ 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) {
|
||||||
if lastEnd < nsRange.lowerBound {
|
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||||||
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
if !beforeText.isEmpty {
|
||||||
if !beforeText.isEmpty {
|
var beforeStyle = AttributeContainer()
|
||||||
var beforeStyle = AttributeContainer()
|
beforeStyle.foregroundColor = baseColor
|
||||||
beforeStyle.foregroundColor = baseColor
|
beforeStyle.font = isSelf
|
||||||
beforeStyle.font = isSelf
|
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||||
? .system(size: 14, weight: .bold, design: .monospaced)
|
: .system(size: 14, design: .monospaced)
|
||||||
: .system(size: 14, design: .monospaced)
|
if isMentioned {
|
||||||
if isMentioned {
|
beforeStyle.font = beforeStyle.font?.bold()
|
||||||
beforeStyle.font = beforeStyle.font?.bold()
|
|
||||||
}
|
|
||||||
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
|
||||||
}
|
}
|
||||||
|
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add styled match
|
// Add styled match
|
||||||
@@ -3423,10 +3380,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Advance lastEnd safely in case of overlaps
|
lastEnd = nsRange.upperBound
|
||||||
if lastEnd < nsRange.upperBound {
|
|
||||||
lastEnd = nsRange.upperBound
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3524,23 +3478,19 @@ 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 nsContent = contentText as NSString
|
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||||
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) {
|
||||||
if lastEndIndex < range.lowerBound {
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
if !beforeText.isEmpty {
|
||||||
if !beforeText.isEmpty {
|
var normalStyle = AttributeContainer()
|
||||||
var normalStyle = AttributeContainer()
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
||||||
normalStyle.font = .system(size: 14, design: .monospaced)
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the mention with highlight
|
// Add the mention with highlight
|
||||||
@@ -3550,7 +3500,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))
|
||||||
|
|
||||||
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
lastEndIndex = range.upperBound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4748,9 +4698,7 @@ 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 nsContent = content as NSString
|
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||||
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()
|
||||||
@@ -5817,12 +5765,9 @@ 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] ?? []
|
||||||
// Dedup by message ID before appending to per-geohash timeline
|
arr.append(finalMessage)
|
||||||
if !arr.contains(where: { $0.id == finalMessage.id }) {
|
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||||
arr.append(finalMessage)
|
geoTimelines[gh] = arr
|
||||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
|
||||||
geoTimelines[gh] = arr
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,8 +165,6 @@ 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 },
|
||||||
@@ -281,10 +279,8 @@ 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
|
|
||||||
let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
ForEach(items, id: \.uiID) { item in
|
||||||
|
|
||||||
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
|
||||||
@@ -1186,13 +1182,6 @@ 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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,17 +304,22 @@ struct LocationChannelsSheet: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let subtitleFull: String = {
|
HStack(spacing: 0) {
|
||||||
if let name = subtitleName, !name.isEmpty {
|
Text(subtitlePrefix)
|
||||||
return subtitlePrefix + " • " + name
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user