From 4d4c596a17a6ac83429e616d6c8814d2cd828120 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 26 Aug 2025 18:39:48 +0200 Subject: [PATCH] UI: diversify peer colors; smarter geo notifications; 21m live location - Use minimal-distance hue palette for mesh and geohash lists; align chat sender colors with list palette. - Add foreground geo notifications for different channels; deep-link to bitchat://geohash/; per-geohash 60s cooldown; respect self/blocks. - Global geohash sampling runs outside the sheet; delegate handles deeplinks and suppresses when already in-channel. - Switch location channel sheet to continuous CoreLocation with 21m distance filter; add config knob. - Only link geohash hashtags when standalone (no @name#abcd or word#abcd). - Include mesh-reachable peers in mesh counts and in "bitchatters nearby" notification. - Add TransportConfig knobs for palette and geo notifications. --- bitchat/BitchatApp.swift | 17 + bitchat/Services/LocationChannelManager.swift | 21 +- bitchat/Services/NotificationService.swift | 9 + bitchat/Services/TransportConfig.swift | 10 + bitchat/ViewModels/ChatViewModel.swift | 408 ++++++++++++++++-- bitchat/Views/LocationChannelsSheet.swift | 14 +- 6 files changed, 438 insertions(+), 41 deletions(-) diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index dddf4588..b4cd3784 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -173,6 +173,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { } } } + // Handle deeplink (e.g., geohash activity) + if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) { + #if os(iOS) + DispatchQueue.main.async { UIApplication.shared.open(url) } + #else + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + #endif + } completionHandler() } @@ -192,6 +200,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { } } } + // Suppress geohash activity notification if we're already in that geohash channel + if identifier.hasPrefix("geo-activity-"), + let deep = userInfo["deeplink"] as? String, + let gh = deep.components(separatedBy: "/").last { + if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh { + completionHandler([]) + return + } + } // Show notification in all other cases completionHandler([.banner, .sound]) diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index 64e992a5..52c7e9b7 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -92,23 +92,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa } } - /// Begin periodic one-shot location refreshes while a selector UI is visible. + /// Begin continuous, distance-filtered updates while the channel sheet is visible. + /// Uses a 21m filter (configurable) to only refresh on meaningful movement. func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) { guard permissionState == .authorized else { return } - // Switch to a lightweight periodic one-shot request (polling) while the sheet is open + // Stop any previous polling timer refreshTimer?.invalidate() - refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in - self?.requestOneShotLocation() - } - // Kick off immediately + refreshTimer = nil + // Tighten accuracy and distance filter for live view + cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters + // Start continuous updates + cl.startUpdatingLocation() + // Request an immediate fix to populate UI without waiting for movement requestOneShotLocation() } - /// Stop periodic refreshes when selector UI is dismissed. + /// Stop continuous refreshes when selector UI is dismissed. func endLiveRefresh() { refreshTimer?.invalidate() refreshTimer = nil cl.stopUpdatingLocation() + // Restore more relaxed defaults for background/idle state + cl.desiredAccuracy = kCLLocationAccuracyHundredMeters + cl.distanceFilter = TransportConfig.locationDistanceFilterMeters } func select(_ channel: ChannelID) { diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 36fdeddd..73ba5bfb 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -90,6 +90,15 @@ class NotificationService { } } + // Geohash public chat notification with deep link to a specific geohash + func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) { + let title = "\(titlePrefix)\(geohash)" + let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)" + let deeplink = "bitchat://geohash/\(geohash)" + let userInfo: [String: Any] = ["deeplink": deeplink] + sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo) + } + func sendNetworkAvailableNotification(peerCount: Int) { let title = "👥 bitchatters nearby!" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 3614a7c9..55c6106d 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -100,8 +100,14 @@ enum TransportConfig { // Location static let locationDistanceFilterMeters: Double = 1000 + // Live (channel sheet open) distance threshold for meaningful updates + static let locationDistanceFilterLiveMeters: Double = 21.0 static let locationLiveRefreshInterval: TimeInterval = 5.0 + // Notifications (geohash) + static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0 + static let uiGeoNotifySnippetMaxLen: Int = 80 + // Nostr geohash static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600 static let nostrGeohashInitialLimit: Int = 200 @@ -169,6 +175,10 @@ enum TransportConfig { // UI color tuning static let uiColorHueAvoidanceDelta: Double = 0.05 static let uiColorHueOffset: Double = 0.12 + // Peer list palette + static let uiPeerPaletteSlots: Int = 36 + static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07 + static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07 // UI windowing (infinite scroll) static let uiWindowInitialCountPublic: Int = 300 diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 860b5125..bc670afb 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -402,6 +402,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @Published private(set) var teleportedGeo: Set = [] // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) private var geoSamplingSubs: [String: String] = [:] // subID -> geohash + private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time // MARK: - Message Delivery Tracking @@ -609,6 +610,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate { Task { @MainActor in self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel) } + + // Background: keep sampling nearby geohashes for notifications even when sheet is closed + LocationChannelManager.shared.$availableChannels + .receive(on: DispatchQueue.main) + .sink { [weak self] channels in + guard let self = self else { return } + let ghs = channels.map { $0.geohash } + Task { @MainActor in + self.beginGeohashSampling(for: ghs) + } + } + .store(in: &cancellables) + // Kick off initial sampling if we already have channels + if !LocationChannelManager.shared.availableChannels.isEmpty { + let ghs = LocationChannelManager.shared.availableChannels.map { $0.geohash } + Task { @MainActor in self.beginGeohashSampling(for: ghs) } + } + // Refresh channels once when authorized to seed sampling + LocationChannelManager.shared.$permissionState + .receive(on: DispatchQueue.main) + .sink { state in + if state == .authorized { LocationChannelManager.shared.refreshChannels() } + } + .store(in: &cancellables) // Request notification permission NotificationService.shared.requestAuthorization() @@ -1743,6 +1768,39 @@ class ChatViewModel: ObservableObject, BitchatDelegate { guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } // Update participants for this specific geohash self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + // Notify on new message activity in this geohash (sampling across channels) + 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 } + // 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) + if UIApplication.shared.applicationState == .active { + if case .location(let ch) = self.activeChannel, ch.geohash == gh { return } + } + #elseif os(macOS) + if NSApplication.shared.isActive { + 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[.. 0 { return true } } return false } + // Helper: check if a hashtag is immediately attached to a preceding @mention (e.g., @name#abcd) + func attachedToMention(_ r: NSRange) -> Bool { + if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex { + var i = content.index(before: nsRange.lowerBound) + while true { + let ch = content[i] + if ch.isWhitespace || ch.isNewline { break } + if ch == "@" { return true } + if i == content.startIndex { break } + i = content.index(before: i) + } + } + return false + } + // Helper: ensure '#' starts a new token (start-of-line or whitespace before '#') + func isStandaloneHashtag(_ r: NSRange) -> Bool { + guard let nsRange = Range(r, in: content) else { return false } + if nsRange.lowerBound == content.startIndex { return true } + let prev = content.index(before: nsRange.lowerBound) + return content[prev].isWhitespace || content[prev].isNewline + } var allMatches: [(range: NSRange, type: String)] = [] - for match in hashtagMatches where !overlapsMention(match.range(at: 0)) { + for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) { allMatches.append((match.range(at: 0), "hashtag")) } for match in mentionMatches { @@ -3144,12 +3223,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } return false }() + // Also require the '#' to start a new token (whitespace or start-of-line before '#') + let standalone: Bool = { + if nsRange.lowerBound == content.startIndex { return true } + let prev = content.index(before: nsRange.lowerBound) + return content[prev].isWhitespace || content[prev].isNewline + }() var tagStyle = AttributeContainer() tagStyle.font = isSelf ? .system(size: 14, weight: .bold, design: .monospaced) : .system(size: 14, design: .monospaced) tagStyle.foregroundColor = baseColor - if isGeohash && !attachedToMention, let url = URL(string: "bitchat://geohash/\(token)") { + if isGeohash && !attachedToMention && standalone, let url = URL(string: "bitchat://geohash/\(token)") { tagStyle.link = url tagStyle.underlineStyle = .single } @@ -3488,14 +3573,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func colorForPeerSeed(_ seed: String, isDark: Bool) -> Color { let cacheKey = seed + (isDark ? "|dark" : "|light") if let cached = peerColorCache[cacheKey] { return cached } - var hue = Double(djb2(seed) % 360) / 360.0 - // Avoid orange (~30°) reserved for self + let h = djb2(seed) + var hue = Double(h % 1000) / 1000.0 let orange = 30.0 / 360.0 if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta { hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0) } - let saturation: Double = isDark ? 0.80 : 0.70 - let brightness: Double = isDark ? 0.75 : 0.45 + let sRand = Double((h >> 17) & 0x3FF) / 1023.0 + let bRand = Double((h >> 27) & 0x3FF) / 1023.0 + let sBase: Double = isDark ? 0.80 : 0.70 + let sRange: Double = 0.20 + let bBase: Double = isDark ? 0.75 : 0.45 + let bRange: Double = isDark ? 0.16 : 0.14 + let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange)) + let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange)) let c = Color(hue: hue, saturation: saturation, brightness: brightness) peerColorCache[cacheKey] = c return c @@ -3503,41 +3594,35 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { - var seed: String if let spid = message.senderPeerID { if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") { - // Normalize to the bare short id, then prefer full mapping when available let bare: String = { if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) } if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) } return spid }() let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased() - seed = "nostr:" + full - } else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() { - seed = "noise:" + full + return getNostrPaletteColor(for: full, isDark: isDark) + } else if spid.count == 16 { + // Mesh short ID + return getPeerPaletteColor(for: spid, isDark: isDark) } else { - seed = spid.lowercased() + return getPeerPaletteColor(for: spid.lowercased(), isDark: isDark) } - } else { - seed = message.sender.lowercased() } - return colorForPeerSeed(seed, isDark: isDark) + // Fallback when we only have a display name + return colorForPeerSeed(message.sender.lowercased(), isDark: isDark) } // Public helpers for views to color peers consistently in lists @MainActor func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { - return colorForPeerSeed("nostr:" + pubkeyHexLowercased.lowercased(), isDark: isDark) + return getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark) } @MainActor func colorForMeshPeer(id peerID: String, isDark: Bool) -> Color { - // Mirror message coloring: prefer stable full noise key mapping when available, else short ID - if let full = getNoiseKeyForShortID(peerID)?.lowercased() { - return colorForPeerSeed("noise:" + full, isDark: isDark) - } - return colorForPeerSeed(peerID.lowercased(), isDark: isDark) + return getPeerPaletteColor(for: peerID, isDark: isDark) } private func trimMeshTimelineIfNeeded() { @@ -3546,6 +3631,276 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } + // MARK: - Peer List Minimal-Distance Palette + private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:] + private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:] + private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used + + @MainActor + private func meshSeed(for peerID: String) -> String { + if let full = getNoiseKeyForShortID(peerID)?.lowercased() { + return "noise:" + full + } + return peerID.lowercased() + } + + @MainActor + private func getPeerPaletteColor(for peerID: String, isDark: Bool) -> Color { + // Ensure palette up to date for current peer set and seeds + rebuildPeerPaletteIfNeeded() + + let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID]) + let orange = Color.orange + if peerID == meshService.myPeerID { return orange } + let saturation: Double = isDark ? 0.80 : 0.70 + let baseBrightness: Double = isDark ? 0.75 : 0.45 + let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight + if let e = entry { + let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring))) + return Color(hue: e.hue, saturation: saturation, brightness: brightness) + } + // Fallback to seed color if not in palette (e.g., transient) + let seed = meshSeed(for: peerID) + return colorForPeerSeed(seed, isDark: isDark) + } + + @MainActor + private func rebuildPeerPaletteIfNeeded() { + // Build current peer->seed map (excluding self) + let myID = meshService.myPeerID + var currentSeeds: [String: String] = [:] + for p in allPeers where p.id != myID { + currentSeeds[p.id] = meshSeed(for: p.id) + } + // If seeds unchanged and palette exists for both themes, skip + if currentSeeds == peerPaletteSeeds, + peerPaletteLight.keys.count == currentSeeds.count, + peerPaletteDark.keys.count == currentSeeds.count { + return + } + peerPaletteSeeds = currentSeeds + + // Generate evenly spaced hue slots avoiding self-orange range + let slotCount = max(8, TransportConfig.uiPeerPaletteSlots) + let avoidCenter = 30.0 / 360.0 + let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta + var slots: [Double] = [] + for i in 0.. Double { + let d = abs(a - b) + return d > 0.5 ? 1.0 - d : d + } + + // Assign slots to peers to maximize minimal distance, deterministically + let peers = currentSeeds.keys.sorted() // stable order + // Preferred slot index by seed (wrapping to available slots) + let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in + let h = djb2(currentSeeds[id] ?? id) + // Map to available slot range deterministically + let idx = Int(h % UInt64(slots.count)) + return (id, idx) + }) + + func assign(for seeds: [String: String]) -> [String: (slot: Int, ring: Int, hue: Double)] { + var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:] + var usedSlots = Set() + var usedHues: [Double] = [] + + // Keep previous assignments if still valid to minimize churn + let prev = peerPaletteLight.isEmpty ? peerPaletteDark : peerPaletteLight + for (id, entry) in prev { + if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid + mapping[id] = (entry.slot, entry.ring, slots[entry.slot]) + usedSlots.insert(entry.slot) + usedHues.append(slots[entry.slot]) + } + } + + // First ring assignment using free slots + let unassigned = peers.filter { mapping[$0] == nil } + for id in unassigned { + // If a preferred slot free, take it + let preferred = prefIndex[id] ?? 0 + if !usedSlots.contains(preferred) && preferred < slots.count { + mapping[id] = (preferred, 0, slots[preferred]) + usedSlots.insert(preferred) + usedHues.append(slots[preferred]) + continue + } + // Choose free slot maximizing minimal distance to used hues + var bestSlot: Int? = nil + var bestScore: Double = -1 + for sIdx in 0.. bestScore { bestScore = score; bestSlot = sIdx } + } + if let s = bestSlot { + mapping[id] = (s, 0, slots[s]) + usedSlots.insert(s) + usedHues.append(slots[s]) + } + } + + // Overflow peers: assign additional rings by reusing slots with stable preference + let stillUnassigned = peers.filter { mapping[$0] == nil } + if !stillUnassigned.isEmpty { + for (idx, id) in stillUnassigned.enumerated() { + let preferred = prefIndex[id] ?? 0 + // Spread over slots by rotating from preferred with a golden-step + let goldenStep = 7 // small prime step for dispersion + let s = (preferred + idx * goldenStep) % slots.count + mapping[id] = (s, 1, slots[s]) + } + } + + return mapping + } + + let mapping = assign(for: currentSeeds) + peerPaletteLight = mapping + peerPaletteDark = mapping + } + + // MARK: - Nostr People Minimal-Distance Palette (same algo) + private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:] + private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:] + private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used + + @MainActor + private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { + rebuildNostrPaletteIfNeeded() + let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased]) + let myHex: String? = { + if case .location(let ch) = LocationChannelManager.shared.selectedChannel, + let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + return id.publicKeyHex.lowercased() + } + return nil + }() + if let me = myHex, pubkeyHexLowercased == me { return .orange } + let saturation: Double = isDark ? 0.80 : 0.70 + let baseBrightness: Double = isDark ? 0.75 : 0.45 + let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight + if let e = entry { + let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring))) + return Color(hue: e.hue, saturation: saturation, brightness: brightness) + } + // Fallback to seed color if not in palette (e.g., transient) + return colorForPeerSeed("nostr:" + pubkeyHexLowercased, isDark: isDark) + } + + @MainActor + private func rebuildNostrPaletteIfNeeded() { + // Build seeds map from currently visible geohash people (excluding self) + let myHex: String? = { + if case .location(let ch) = LocationChannelManager.shared.selectedChannel, + let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + return id.publicKeyHex.lowercased() + } + return nil + }() + let people = visibleGeohashPeople() + var currentSeeds: [String: String] = [:] + for p in people where p.id != myHex { currentSeeds[p.id] = "nostr:" + p.id } + + if currentSeeds == nostrPaletteSeeds, + nostrPaletteLight.keys.count == currentSeeds.count, + nostrPaletteDark.keys.count == currentSeeds.count { + return + } + nostrPaletteSeeds = currentSeeds + + let slotCount = max(8, TransportConfig.uiPeerPaletteSlots) + let avoidCenter = 30.0 / 360.0 + let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta + var slots: [Double] = [] + for i in 0.. Double { + let d = abs(a - b) + return d > 0.5 ? 1.0 - d : d + } + + let peers = currentSeeds.keys.sorted() + let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in + let h = djb2(currentSeeds[id] ?? id) + let idx = Int(h % UInt64(slots.count)) + return (id, idx) + }) + + var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:] + var usedSlots = Set() + var usedHues: [Double] = [] + + let prev = nostrPaletteLight.isEmpty ? nostrPaletteDark : nostrPaletteLight + for (id, entry) in prev { + if peers.contains(id), entry.slot < slots.count { + mapping[id] = (entry.slot, entry.ring, slots[entry.slot]) + usedSlots.insert(entry.slot) + usedHues.append(slots[entry.slot]) + } + } + + let unassigned = peers.filter { mapping[$0] == nil } + for id in unassigned { + let preferred = prefIndex[id] ?? 0 + if !usedSlots.contains(preferred) && preferred < slots.count { + mapping[id] = (preferred, 0, slots[preferred]) + usedSlots.insert(preferred) + usedHues.append(slots[preferred]) + continue + } + var bestSlot: Int? = nil + var bestScore: Double = -1 + for sIdx in 0.. bestScore { bestScore = score; bestSlot = sIdx } + } + if let s = bestSlot { + mapping[id] = (s, 0, slots[s]) + usedSlots.insert(s) + usedHues.append(slots[s]) + } + } + + let stillUnassigned = peers.filter { mapping[$0] == nil } + if !stillUnassigned.isEmpty { + for (idx, id) in stillUnassigned.enumerated() { + let preferred = prefIndex[id] ?? 0 + let goldenStep = 7 + let s = (preferred + idx * goldenStep) % slots.count + mapping[id] = (s, 1, slots[s]) + } + } + + nostrPaletteLight = mapping + nostrPaletteDark = mapping + } + // Clear the current public channel's timeline (visible + persistent buffer) @MainActor func clearCurrentPublicTimeline() { @@ -4106,9 +4461,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Cancel any pending reset if peers are back self.networkResetTimer?.invalidate() self.networkResetTimer = nil - // Only count mesh peers (actually connected via Bluetooth) + // Count mesh peers that are connected OR recently reachable via mesh relays let meshPeers = peers.filter { peerID in - self.meshService.isPeerConnected(peerID) + self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID) } // Check if we have new mesh peers we haven't seen recently @@ -5338,7 +5693,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if now.timeIntervalSince(lastNotified) >= 60 { let title = activeChannelDisplayName() let body = "new chats!" - NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)") + 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 } } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index c878d8be..cf9f79d6 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -83,24 +83,17 @@ struct LocationChannelsSheet: View { } // Begin periodic refresh while sheet is open manager.beginLiveRefresh() - // Begin multi-channel sampling for counts - let ghs = manager.availableChannels.map { $0.geohash } - viewModel.beginGeohashSampling(for: ghs) + // Geohash sampling is now managed by ChatViewModel globally } .onDisappear { manager.endLiveRefresh() - viewModel.endGeohashSampling() } .onChange(of: manager.permissionState) { newValue in if newValue == LocationChannelManager.PermissionState.authorized { manager.refreshChannels() } } - .onChange(of: manager.availableChannels) { newValue in - // Keep sampling list in sync with available channels as they refresh live - let ghs = newValue.map { $0.geohash } - viewModel.beginGeohashSampling(for: ghs) - } + .onChange(of: manager.availableChannels) { _ in } } private var channelList: some View { @@ -289,9 +282,10 @@ struct LocationChannelsSheet: View { } private func meshCount() -> Int { + // Count mesh-connected OR mesh-reachable peers (exclude self) let myID = viewModel.meshService.myPeerID return viewModel.allPeers.reduce(0) { acc, peer in - if peer.id != myID && peer.isConnected { return acc + 1 } + if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 } return acc } }