diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index 52c7e9b7..99ea62b7 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -50,10 +50,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa let arr = try? JSONDecoder().decode([String].self, from: data) { teleportedSet = Set(arr) } - // Initialize teleported flag from persisted state if a location channel is selected - if case .location(let ch) = selectedChannel { - teleported = teleportedSet.contains(ch.geohash) - } + // Do not eagerly mark teleported on startup; wait for location to compute regional set. + // This avoids showing teleported for in-region channels during cold start. let status: CLAuthorizationStatus if #available(iOS 14.0, macOS 11.0, *) { status = cl.authorizationStatus @@ -61,6 +59,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa status = CLLocationManager.authorizationStatus() } updatePermissionState(from: status) + // If we don't have location authorization at startup, fall back to persisted teleport state + switch status { + case .authorizedAlways, .authorizedWhenInUse, .authorized: + break // will compute from location + default: + if case .location(let ch) = selectedChannel { + teleported = teleportedSet.contains(ch.geohash) + } + } } // MARK: - Public API @@ -129,7 +136,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa case .mesh: self.teleported = false case .location(let ch): - self.teleported = self.teleportedSet.contains(ch.geohash) + // If this geohash is in our current regional set, do NOT mark teleported. + let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash } + if inRegional { + self.teleported = false + // Clear persisted teleport for this geohash to keep future selections clean + if self.teleportedSet.contains(ch.geohash) { + self.teleportedSet.remove(ch.geohash) + if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) { + UserDefaults.standard.set(data, forKey: self.teleportedStoreKey) + } + } + } else { + // Fall back to persisted mark (set by deep link or manual teleport) + self.teleported = self.teleportedSet.contains(ch.geohash) + } } } } @@ -202,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa } Task { @MainActor in self.availableChannels = result - // Recompute teleported status based on persisted state OR current location vs selected channel + // Recompute teleported status based on whether the selected geohash is in our regional set switch self.selectedChannel { case .mesh: self.teleported = false case .location(let ch): - let persisted = self.teleportedSet.contains(ch.geohash) - let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision) - self.teleported = persisted || (currentGH != ch.geohash) + // Membership check using freshly computed regional channels; avoids precision/rename drift + let inRegional = result.contains { $0.geohash == ch.geohash } + if inRegional { + self.teleported = false + // Clear persisted teleport flag if present + if self.teleportedSet.contains(ch.geohash) { + self.teleportedSet.remove(ch.geohash) + if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) { + UserDefaults.standard.set(data, forKey: self.teleportedStoreKey) + } + } + } else { + self.teleported = true + } } } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index bc670afb..054df669 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -634,6 +634,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if state == .authorized { LocationChannelManager.shared.refreshChannels() } } .store(in: &cancellables) + + // Track teleport flag changes to keep our own teleported marker in sync with regional status + LocationChannelManager.shared.$teleported + .receive(on: DispatchQueue.main) + .sink { [weak self] isTeleported in + guard let self = self else { return } + Task { @MainActor in + guard case .location(let ch) = self.activeChannel, + let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return } + let key = id.publicKeyHex.lowercased() + let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } + if isTeleported && hasRegional && !inRegional { + self.teleportedGeo = self.teleportedGeo.union([key]) + } else { + self.teleportedGeo.remove(key) + } + } + } + .store(in: &cancellables) // Request notification permission NotificationService.shared.requestAuthorization() @@ -772,10 +792,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { }) if hasTeleportTag { let key = event.pubkey.lowercased() - Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) } + // Do not mark our own key from historical events; rely on manager.teleported for self + let isSelf: Bool = { + if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) { + return my.publicKeyHex.lowercased() == key + } + return false + }() + if !isSelf { + Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) } + } } let senderName = self.displayNameForNostrPubkey(event.pubkey) - let content = event.content + let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let mentions = self.parseMentions(from: content) let msg = BitchatMessage( @@ -1217,7 +1246,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { /// Routes to private chat if one is selected, otherwise broadcasts @MainActor func sendMessage(_ content: String) { - guard !content.isEmpty else { return } + // Ignore messages that are empty or whitespace-only to prevent blank lines + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } // Check for commands if content.hasPrefix("/") { @@ -1237,7 +1268,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } else { } } else { - // Parse mentions from the content + // Parse mentions from the content (use original content for user intent) let mentions = parseMentions(from: content) // Add message to local display @@ -1252,7 +1283,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let message = BitchatMessage( sender: displaySender, - content: content, + content: trimmed, timestamp: Date(), isRelay: false, originalSender: nil, @@ -1297,7 +1328,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { do { let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( - content: content, + content: trimmed, geohash: ch.geohash, senderIdentity: identity, nickname: self.nickname, @@ -1317,7 +1348,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: SecureLogger.session, level: .debug) // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI - if LocationChannelManager.shared.teleported { + // Only when not in our regional set (and regional list is known) + let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } + if LocationChannelManager.shared.teleported && hasRegional && !inRegional { let key = identity.publicKeyHex.lowercased() self.teleportedGeo = self.teleportedGeo.union([key]) SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", @@ -1354,9 +1388,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { case .location(let ch): // Sanitize existing timeline (filter any prior empty-content entries) var arr = geoTimelines[ch.geohash] ?? [] - let before = arr.count arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - if arr.count != before { geoTimelines[ch.geohash] = arr } + // Ensure chronological order when returning to a geohash + if arr.count > 1 { + arr.sort { $0.timestamp < $1.timestamp } + } + // Persist the cleaned/sorted timeline for this geohash + geoTimelines[ch.geohash] = arr messages = arr } // Unsubscribe previous @@ -1375,14 +1413,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate { guard case .location(let ch) = channel else { return } currentGeohash = ch.geohash - // Ensure self appears immediately in the people list; mark teleported state if applicable + // Ensure self appears immediately in the people list; mark teleported state only when truly teleported if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { self.recordGeoParticipant(pubkeyHex: id.publicKeyHex) - if LocationChannelManager.shared.teleported { - let key = id.publicKeyHex.lowercased() + let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } + let key = id.publicKeyHex.lowercased() + if LocationChannelManager.shared.teleported && hasRegional && !inRegional { teleportedGeo = teleportedGeo.union([key]) SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: SecureLogger.session, level: .info) + } else { + teleportedGeo.remove(key) } } let subID = "geo-\(ch.geohash)" @@ -1411,10 +1453,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { }) if hasTeleportTag { let key = event.pubkey.lowercased() - Task { @MainActor in - self.teleportedGeo = self.teleportedGeo.union([key]) - SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", - category: SecureLogger.session, level: .info) + // Avoid marking our own key from historical events; rely on manager.teleported for self + let isSelf: Bool = { + if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) { + return my.publicKeyHex.lowercased() == key + } + return false + }() + if !isSelf { + Task { @MainActor in + self.teleportedGeo = self.teleportedGeo.union([key]) + SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", + category: SecureLogger.session, level: .info) + } } } // Skip only very recent self-echo from relay; include older self events for hydration @@ -1766,15 +1817,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in guard let self = self else { return } 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: Int = { + let map = self.geoParticipants[gh] ?? [:] + return map.values.filter { $0 >= cutoff }.count + }() // Update participants for this specific geohash self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) - // Notify on new message activity in this geohash (sampling across channels) + // 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 SecureIdentityStateManager.shared.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 policy: allow if it's a different geohash than the one currently open // Suppress only when app is active AND we're already in this same geohash channel #if os(iOS) @@ -4288,11 +4350,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) { Task { @MainActor in - let publicMentions = parseMentions(from: content) + let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines) + let publicMentions = parseMentions(from: normalized) let msg = BitchatMessage( id: UUID().uuidString, sender: nickname, - content: content, + content: normalized, timestamp: timestamp, isRelay: false, originalSender: nil, @@ -5677,34 +5740,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { guard channelMatches else { return } - // Background nudge: notify on new activity after inactivity threshold in current channel - #if os(iOS) - if UIApplication.shared.applicationState != .active { - let channelKey: String = { - switch activeChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - let now = Date() - if let last = lastPublicActivityAt[channelKey], now.timeIntervalSince(last) >= channelInactivityThreshold { - // Optional: simple cooldown to avoid duplicate bursts - let lastNotified = lastPublicActivityNotifyAt[channelKey] ?? .distantPast - if now.timeIntervalSince(lastNotified) >= 60 { - let title = activeChannelDisplayName() - let body = "new chats!" - if case .location(let ch) = activeChannel { - // Attach deep link to open this geohash directly - NotificationService.shared.sendGeohashActivityNotification(geohash: ch.geohash, titlePrefix: title + " ", bodyPreview: body) - } else { - NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)") - } - lastPublicActivityNotifyAt[channelKey] = now - } - } - lastPublicActivityAt[channelKey] = now - } - #endif + // Append via batching buffer (skip empty content) if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 83b51bff..71faf085 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -459,7 +459,13 @@ struct ContentView: View { } let level = levelForLength(gh.count) let ch = GeohashChannel(level: level, geohash: gh) - LocationChannelManager.shared.markTeleported(for: gh, true) + // Do not mark teleported when opening a geohash that is in our regional set. + // If availableChannels is empty (e.g., cold start), defer marking and let + // LocationChannelManager compute teleported based on actual location. + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh } + if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty { + LocationChannelManager.shared.markTeleported(for: gh, true) + } LocationChannelManager.shared.select(ChannelID.location(ch)) } .onTapGesture(count: 3) {