mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be722aa170 | ||
|
|
e79bcf531b |
@@ -44,7 +44,12 @@ class NostrRelayManager: ObservableObject {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// Message queue for reliability
|
||||
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = []
|
||||
// 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 let messageQueueLock = NSLock()
|
||||
|
||||
// Exponential backoff configuration
|
||||
@@ -95,16 +100,57 @@ class NostrRelayManager: ObservableObject {
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||
ensureConnections(to: targetRelays)
|
||||
|
||||
// Add to queue for reliability
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append((event, targetRelays))
|
||||
messageQueueLock.unlock()
|
||||
|
||||
// Attempt immediate send
|
||||
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
var stillPending = Set<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,6 +435,10 @@ 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,7 +223,30 @@ final class BLEService: NSObject {
|
||||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||||
} else {
|
||||
self.collectionsQueue.async(flags: .barrier) {
|
||||
self.pendingPeripheralWrites[uuid, default: []].append(data)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,19 @@ final class MessageRouter {
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: String) -> Bool {
|
||||
guard let noiseKey = Data(hexString: peerID) else { return false }
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -101,6 +110,7 @@ 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) {
|
||||
@@ -112,14 +122,19 @@ final class MessageRouter {
|
||||
category: SecureLogger.session, level: .debug)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else {
|
||||
continue
|
||||
// Keep unsent items queued
|
||||
remaining.append((content, nickname, messageID))
|
||||
}
|
||||
}
|
||||
// Remove all flushed items (remaining ones, if any, will be re-queued on next call)
|
||||
outbox[peerID]?.removeAll()
|
||||
// Persist only items we could not send
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
}
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
for key in outbox.keys { flushOutbox(for: key) }
|
||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ 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>()
|
||||
|
||||
@@ -330,8 +331,13 @@ 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
|
||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Force update of peers to reflect the change
|
||||
updatePeers()
|
||||
|
||||
@@ -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
|
||||
@@ -487,6 +492,8 @@ 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
|
||||
@@ -1407,6 +1414,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 +1426,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 +2613,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 +2643,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 +2694,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",
|
||||
@@ -5781,9 +5817,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,8 @@ struct ContentView: View {
|
||||
}
|
||||
.sheet(isPresented: $showAppInfo) {
|
||||
AppInfoView()
|
||||
.onAppear { viewModel.isAppInfoPresented = true }
|
||||
.onDisappear { viewModel.isAppInfoPresented = false }
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { viewModel.showingFingerprintFor != nil },
|
||||
@@ -279,8 +281,10 @@ struct ContentView: View {
|
||||
}
|
||||
}()
|
||||
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
|
||||
|
||||
ForEach(items, id: \.uiID) { item in
|
||||
// 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
|
||||
let message = item.message
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Check if current user is mentioned
|
||||
@@ -1182,6 +1186,13 @@ 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,22 +304,17 @@ struct LocationChannelsSheet: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
let subtitleFull: String = {
|
||||
if let name = subtitleName, !name.isEmpty {
|
||||
return subtitlePrefix + " • " + name
|
||||
}
|
||||
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