diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 0aff1630..054ec166 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -2042,95 +2042,110 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let toAdd = desired.subtracting(current) let toRemove = current.subtracting(desired) - // for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) { NostrRelayManager.shared.unsubscribe(id: subID) geoSamplingSubs.removeValue(forKey: subID) } - // Subscribe new for gh in toAdd { - let subID = "geo-sample-\(gh)" - geoSamplingSubs[subID] = gh - let filter = NostrFilter.geohashEphemeral( - gh, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), - limit: TransportConfig.nostrGeohashSampleLimit + subscribe(gh) + } + } + + @MainActor + private func subscribe(_ gh: String) { + let subID = "geo-sample-\(gh)" + geoSamplingSubs[subID] = gh + let filter = NostrFilter.geohashEphemeral( + gh, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), + limit: TransportConfig.nostrGeohashSampleLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + self?.subscribeNostrEvent(event, gh: gh) + } + } + + private func subscribeNostrEvent(_ event: NostrEvent, gh: String) { + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + + // Compute current participant count (5-minute window) BEFORE updating with this event + let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) + let existingCount = geoParticipants[gh]?.values.filter { $0 >= cutoff }.count ?? 0 + + // Update participants for this specific geohash + recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + + // Notify only on rising-edge: previously zero people, now someone sends a chat + let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return } + + // Respect geohash blocks + if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } + + // Skip self identity for this geohash + if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } + + // Only trigger when there were zero participants in this geohash recently + guard existingCount == 0 else { return } + + // Avoid notifications for old sampled events when launching or (re)subscribing + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) > 30 { return } + + // Foreground-only notifications: app must be active, and not already viewing this geohash + #if os(iOS) + guard UIApplication.shared.applicationState == .active else { return } + if case .location(let ch) = activeChannel, ch.geohash == gh { return } + #elseif os(macOS) + guard NSApplication.shared.isActive else { return } + if case .location(let ch) = activeChannel, ch.geohash == gh { return } + #endif + + cooldownPerGeohash(gh, content: content, event: event) + } + + private func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { + let now = Date() + let last = lastGeoNotificationAt[gh] ?? .distantPast + if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } + + // Compose a short preview + let preview: String = { + let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen + if content.count <= maxLen { return content } + let idx = content.index(content.startIndex, offsetBy: maxLen) + return String(content[..= cutoff }.count - }() - // Update participants for this specific geohash - self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) - // Notify only on rising-edge: previously zero people, now someone sends a chat - let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) - guard !content.isEmpty else { return } - // Respect geohash blocks - if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } - // Skip self identity for this geohash - if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } - // Only trigger when there were zero participants in this geohash recently - guard existingCount == 0 else { return } - // Avoid notifications for old sampled events when launching or (re)subscribing - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) > 30 { return } - // Foreground-only notifications: app must be active, and not already viewing this geohash - #if os(iOS) - guard UIApplication.shared.applicationState == .active else { return } - if case .location(let ch) = self.activeChannel, ch.geohash == gh { return } - #elseif os(macOS) - guard NSApplication.shared.isActive else { return } - if case .location(let ch) = self.activeChannel, ch.geohash == gh { return } - #endif - // Cooldown per geohash - let now = Date() - let last = self.lastGeoNotificationAt[gh] ?? .distantPast - if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } - // Compose a short preview - let preview: String = { - let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen - if content.count <= maxLen { return content } - let idx = content.index(content.startIndex, offsetBy: maxLen) - return String(content[.. self.geoTimelineCap { arr = Array(arr.suffix(self.geoTimelineCap)) } - self.geoTimelines[gh] = arr - } - NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview) - } + if !arr.contains(where: { $0.id == msg.id }) { + arr.append(msg) + if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } + geoTimelines[gh] = arr } + NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview) } }