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 56a5672b..f074ab64 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -659,6 +659,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() @@ -797,10 +817,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( @@ -1242,7 +1271,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("/") { @@ -1262,7 +1293,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 @@ -1277,7 +1308,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let message = BitchatMessage( sender: displaySender, - content: content, + content: trimmed, timestamp: Date(), isRelay: false, originalSender: nil, @@ -1322,7 +1353,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, @@ -1342,7 +1373,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)", @@ -1379,9 +1413,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 @@ -1400,14 +1438,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)" @@ -1436,10 +1478,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 @@ -1791,7 +1842,7 @@ 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 existing participant count (5-minute window) BEFORE updating + // 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] ?? [:] @@ -1799,7 +1850,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { }() // Update participants for this specific geohash self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) - // Notify only on rising-edge: previously zero people, now someone chats + // 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 @@ -4347,11 +4398,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, diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 95a82169..cc757ad7 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -460,7 +460,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) {