mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:25:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b363b7062 | ||
|
|
7d672f2d69 | ||
|
|
d35d3f9612 | ||
|
|
9e79b18dcb |
@@ -44,12 +44,7 @@ class NostrRelayManager: ObservableObject {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// Message queue for reliability
|
||||
// Pending sends held only for relays that are not yet connected.
|
||||
private struct PendingSend {
|
||||
var event: NostrEvent
|
||||
var pendingRelays: Set<String>
|
||||
}
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
|
||||
// Exponential backoff configuration
|
||||
@@ -100,57 +95,16 @@ class NostrRelayManager: ObservableObject {
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
var stillPending = Set<String>()
|
||||
|
||||
// Add to queue for reliability
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append((event, targetRelays))
|
||||
messageQueueLock.unlock()
|
||||
|
||||
// Attempt immediate send
|
||||
for relayUrl in targetRelays {
|
||||
if let connection = connections[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()
|
||||
// If we just connected to this relay, flush any queued sends targeting it
|
||||
if isConnected {
|
||||
flushMessageQueue(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateConnectionStatus() {
|
||||
|
||||
@@ -223,30 +223,7 @@ final class BLEService: NSObject {
|
||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
} else {
|
||||
self.collectionsQueue.async(flags: .barrier) {
|
||||
var queue = self.pendingPeripheralWrites[uuid] ?? []
|
||||
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
|
||||
}
|
||||
self.pendingPeripheralWrites[uuid, default: []].append(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,19 +89,10 @@ final class MessageRouter {
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: String) -> Bool {
|
||||
// Two forms are supported:
|
||||
// - 64-hex Noise public key (32 bytes)
|
||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
||||
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
|
||||
}
|
||||
guard let noiseKey = Data(hexString: peerID) else { return false }
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -110,7 +101,6 @@ final class MessageRouter {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
@@ -122,19 +112,14 @@ final class MessageRouter {
|
||||
category: SecureLogger.session, level: .debug)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else {
|
||||
// Keep unsent items queued
|
||||
remaining.append((content, nickname, messageID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Persist only items we could not send
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
}
|
||||
// Remove all flushed items (remaining ones, if any, will be re-queued on next call)
|
||||
outbox[peerID]?.removeAll()
|
||||
}
|
||||
|
||||
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 fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||
private let meshService: Transport
|
||||
weak var messageRouter: MessageRouter?
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
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))",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Send favorite notification to the peer via router (mesh or Nostr)
|
||||
if let router = messageRouter {
|
||||
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||
} else {
|
||||
// Fallback to mesh-only if router not yet wired
|
||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||
}
|
||||
// Send favorite notification to the peer
|
||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||
|
||||
// Force update of peers to reflect the change
|
||||
updatePeers()
|
||||
|
||||
@@ -382,11 +382,6 @@ 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
|
||||
@@ -492,8 +487,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||
// Route receipts from PrivateChatManager through MessageRouter
|
||||
self.privateChatManager.messageRouter = self.messageRouter
|
||||
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
|
||||
self.unifiedPeerService.messageRouter = self.messageRouter
|
||||
self.autocompleteService = AutocompleteService()
|
||||
|
||||
// Wire up dependencies
|
||||
@@ -1414,11 +1407,6 @@ 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()
|
||||
@@ -1426,26 +1414,13 @@ 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 }
|
||||
// Deduplicate by ID while preserving order (from oldest to newest)
|
||||
// Ensure chronological order when returning to a geohash
|
||||
if arr.count > 1 {
|
||||
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
|
||||
arr.sort { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
// 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 {
|
||||
@@ -2613,17 +2588,6 @@ 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 *"
|
||||
|
||||
@@ -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(
|
||||
sender: "system",
|
||||
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(
|
||||
sender: "system",
|
||||
content: "you took a screenshot",
|
||||
@@ -3079,10 +3043,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
||||
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||
|
||||
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)) ?? []
|
||||
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)) ?? []
|
||||
|
||||
// Combine and sort all matches
|
||||
var allMatches: [(range: NSRange, type: String)] = []
|
||||
@@ -3099,14 +3061,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for (matchRange, matchType) in allMatches {
|
||||
// Add text before the match
|
||||
if let range = Range(matchRange, in: contentText) {
|
||||
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))
|
||||
}
|
||||
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
|
||||
@@ -3124,7 +3084,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
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,
|
||||
// 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: 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 {
|
||||
var plainStyle = AttributeContainer()
|
||||
@@ -3224,6 +3181,8 @@ 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")
|
||||
@@ -3303,19 +3262,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for (range, type) in allMatches {
|
||||
// Add text before match
|
||||
if let nsRange = Range(range, in: content) {
|
||||
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))
|
||||
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))
|
||||
}
|
||||
|
||||
// Add styled match
|
||||
@@ -3423,10 +3380,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||
}
|
||||
}
|
||||
// Advance lastEnd safely in case of overlaps
|
||||
if lastEnd < nsRange.upperBound {
|
||||
lastEnd = nsRange.upperBound
|
||||
}
|
||||
lastEnd = nsRange.upperBound
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3524,23 +3478,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Regular expression to find @mentions
|
||||
let pattern = "@([\\p{L}0-9_]+)"
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||
let nsContent = contentText as NSString
|
||||
let nsLen = nsContent.length
|
||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
||||
|
||||
var lastEndIndex = contentText.startIndex
|
||||
|
||||
for match in matches {
|
||||
// Add text before the mention
|
||||
if let range = Range(match.range(at: 0), in: contentText) {
|
||||
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))
|
||||
}
|
||||
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
|
||||
@@ -3550,7 +3500,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
mentionStyle.foregroundColor = Color.orange
|
||||
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
|
||||
let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||
let nsContent = content as NSString
|
||||
let nsLen = nsContent.length
|
||||
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||
|
||||
var mentions: [String] = []
|
||||
let peerNicknames = meshService.getPeerNicknames()
|
||||
@@ -5817,12 +5765,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if isGeo && finalMessage.sender != "system" {
|
||||
if let gh = currentGeohash {
|
||||
var arr = geoTimelines[gh] ?? []
|
||||
// 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
|
||||
}
|
||||
arr.append(finalMessage)
|
||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||
geoTimelines[gh] = arr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,8 +165,6 @@ struct ContentView: View {
|
||||
}
|
||||
.sheet(isPresented: $showAppInfo) {
|
||||
AppInfoView()
|
||||
.onAppear { viewModel.isAppInfoPresented = true }
|
||||
.onDisappear { viewModel.isAppInfoPresented = false }
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { viewModel.showingFingerprintFor != nil },
|
||||
@@ -281,10 +279,8 @@ struct ContentView: View {
|
||||
}
|
||||
}()
|
||||
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(filteredItems, id: \.uiID) { item in
|
||||
|
||||
ForEach(items, id: \.uiID) { item in
|
||||
let message = item.message
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Check if current user is mentioned
|
||||
@@ -1186,13 +1182,6 @@ struct ContentView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.sheet(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))
|
||||
}
|
||||
|
||||
@@ -304,17 +304,22 @@ struct LocationChannelsSheet: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
let subtitleFull: String = {
|
||||
if let name = subtitleName, !name.isEmpty {
|
||||
return subtitlePrefix + " • " + name
|
||||
HStack(spacing: 0) {
|
||||
Text(subtitlePrefix)
|
||||
.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()
|
||||
if isSelected {
|
||||
|
||||
Reference in New Issue
Block a user