From 806d45113541a15affd732dde889c2c737cf1db0 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 11:43:44 +0200 Subject: [PATCH 01/15] Feature: assign stable per-peer colors (non-self) across mesh/geohash/DM; keep self orange and mentions-to-me orange; cache colors --- bitchat/ViewModels/ChatViewModel.swift | 43 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 1ebaf7ee..4c6a37e3 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -2707,7 +2707,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { var result = AttributedString() let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - let baseColor: Color = isSelf ? .orange : primaryColor + let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) if message.sender != "system" { // Sender (at the beginning) with light-gray suffix styling if present @@ -2812,7 +2812,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { }() var mentionStyle = AttributeContainer() mentionStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) - let mentionColor: Color = isMentionToMe ? .orange : primaryColor + let mentionColor: Color = isMentionToMe ? .orange : baseColor mentionStyle.foregroundColor = mentionColor // Emit '@' result.append(AttributedString("@").mergingAttributes(mentionStyle)) @@ -3135,6 +3135,45 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } + // MARK: - Per-Peer Colors + private var peerColorCache: [String: Color] = [:] + + private func djb2(_ s: String) -> UInt64 { + var hash: UInt64 = 5381 + for b in s.utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) } + return hash + } + + private func colorForPeerSeed(_ seed: String, isDark: Bool) -> Color { + if let cached = peerColorCache[seed] { return cached } + var hue = Double(djb2(seed) % 360) / 360.0 + // Avoid orange (~30°) reserved for self + let orange = 30.0 / 360.0 + if abs(hue - orange) < 0.05 { hue = fmod(hue + 0.12, 1.0) } + let saturation: Double = isDark ? 0.80 : 0.70 + let brightness: Double = isDark ? 0.75 : 0.45 + let c = Color(hue: hue, saturation: saturation, brightness: brightness) + peerColorCache[seed] = c + return c + } + + private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { + var seed: String + if let spid = message.senderPeerID { + if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") { + let full = nostrKeyMapping[spid]?.lowercased() ?? spid.lowercased() + seed = "nostr:" + full + } else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() { + seed = "noise:" + full + } else { + seed = spid.lowercased() + } + } else { + seed = message.sender.lowercased() + } + return colorForPeerSeed(seed, isDark: isDark) + } + private func trimMeshTimelineIfNeeded() { if meshTimeline.count > meshTimelineCap { let removeCount = meshTimeline.count - meshTimelineCap From e82fda1093203e6b408796d0db4270c8a9c5b619 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 11:45:49 +0200 Subject: [PATCH 02/15] Fix compile warnings: remove unused primaryColor in formatMessageAsText; remove unused CryptoKit import; mark peerColor/formatMessageAsText @MainActor to call main-actor helpers --- bitchat/ViewModels/ChatViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4c6a37e3..9c0ba291 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -80,7 +80,6 @@ import Foundation import SwiftUI import Combine -import CryptoKit import CommonCrypto import CoreBluetooth #if os(iOS) @@ -2679,6 +2678,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return processedContent } + @MainActor func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { // Determine if this message was sent by self (mesh, geo, or DM) let isSelf: Bool = { @@ -2706,7 +2706,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Not cached, format the message var result = AttributedString() - let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) if message.sender != "system" { @@ -3157,6 +3156,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return c } + @MainActor private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { var seed: String if let spid = message.senderPeerID { From c4b422ad3fd912b4718c285be32190ba61cb1a93 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 11:47:21 +0200 Subject: [PATCH 03/15] Fix: re-import CryptoKit for SHA256 usage in ChatViewModel --- bitchat/ViewModels/ChatViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 9c0ba291..4cb934ec 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -79,6 +79,7 @@ import Foundation import SwiftUI +import CryptoKit import Combine import CommonCrypto import CoreBluetooth From 5650e1f2c2b67240dedc97281e87facaeb99a034 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 11:49:44 +0200 Subject: [PATCH 04/15] Peer lists: apply same per-peer colors as chat (self orange); suffix uses lighter variant; geohash uses Nostr pubkey, mesh uses Noise key --- bitchat/ViewModels/ChatViewModel.swift | 18 +++++++++++++++++- bitchat/Views/GeohashPeopleList.swift | 6 ++++-- bitchat/Views/MeshPeerList.swift | 4 +++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4cb934ec..519ef0df 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -3144,7 +3144,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return hash } - private func colorForPeerSeed(_ seed: String, isDark: Bool) -> Color { + @MainActor + func colorForPeerSeed(_ seed: String, isDark: Bool) -> Color { if let cached = peerColorCache[seed] { return cached } var hue = Double(djb2(seed) % 360) / 360.0 // Avoid orange (~30°) reserved for self @@ -3175,6 +3176,21 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return colorForPeerSeed(seed, 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) + } + + @MainActor + func colorForMeshPeer(id peerID: String, isDark: Bool) -> Color { + if let peer = unifiedPeerService.getPeer(by: peerID) { + let full = peer.noisePublicKey.hexEncodedString().lowercased() + return colorForPeerSeed("noise:" + full, isDark: isDark) + } + return colorForPeerSeed(peerID.lowercased(), isDark: isDark) + } + private func trimMeshTimelineIfNeeded() { if meshTimeline.count > meshTimelineCap { let removeCount = meshTimeline.count - meshTimelineCap diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index e8604b97..69528c61 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -6,6 +6,7 @@ struct GeohashPeopleList: View { let textColor: Color let secondaryTextColor: Color let onTapPerson: () -> Void + @Environment(\.colorScheme) var colorScheme var body: some View { Group { @@ -51,14 +52,15 @@ struct GeohashPeopleList: View { } let (base, suffix) = splitSuffix(from: person.displayName) let isMe = person.id == myHex + let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) HStack(spacing: 0) { - let rowColor: Color = isMe ? .orange : textColor + let rowColor: Color = isMe ? .orange : assignedColor Text(base) .font(.system(size: 14, design: .monospaced)) .fontWeight(isMe ? .bold : .regular) .foregroundColor(rowColor) if !suffix.isEmpty { - let suffixColor = isMe ? Color.orange.opacity(0.6) : textColor.opacity(0.6) + let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) Text(suffix) .font(.system(size: 14, design: .monospaced)) .foregroundColor(suffixColor) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 14ca6a1b..ccc62fa3 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -7,6 +7,7 @@ struct MeshPeerList: View { let onTapPeer: (String) -> Void let onToggleFavorite: (String) -> Void let onShowFingerprint: (String) -> Void + @Environment(\.colorScheme) var colorScheme var body: some View { Group { @@ -61,7 +62,8 @@ struct MeshPeerList: View { let displayName = isMe ? viewModel.nickname : peer.nickname let (base, suffix) = splitSuffix(from: displayName) - let baseColor = ((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil) ? textColor : secondaryTextColor + let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark) + let baseColor = isMe ? Color.orange : assigned HStack(spacing: 0) { Text(base) .font(.system(size: 14, design: .monospaced)) From 7b492686947f11b1c3bc723bde72216cdbf60ab6 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 11:51:17 +0200 Subject: [PATCH 05/15] Fix warning: remove unused peerNicknames in MeshPeerList --- bitchat/Views/MeshPeerList.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index ccc62fa3..79941fc0 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -18,7 +18,6 @@ struct MeshPeerList: View { .padding(.horizontal) .padding(.top, 12) } else { - let peerNicknames = viewModel.meshService.getPeerNicknames() let myPeerID = viewModel.meshService.myPeerID let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in let isMe = peer.id == myPeerID From 0d064084aa0de7ba7470a92f1f5ea9c019b3e637 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 12:12:49 +0200 Subject: [PATCH 06/15] UI: add 'mention' action in message actions to prefill input with @nick#abcd and focus input --- bitchat/Views/ContentView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 2cedae5a..33b72031 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -204,6 +204,14 @@ struct ContentView: View { isPresented: $showMessageActions, titleVisibility: .visible ) { + Button("mention") { + if let sender = selectedMessageSender { + // Pre-fill the input with an @mention and focus the field + messageText = "@\(sender) " + isTextFieldFocused = true + } + } + Button("private message") { if let peerID = selectedMessageSenderID { #if os(iOS) From 244c8cdf81db1b4747cdf1dd9445fbceab4d376c Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 12:13:59 +0200 Subject: [PATCH 07/15] UX: long-press a message to prefill @mention with sender's full nick#abcd and focus input --- bitchat/Views/ContentView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 33b72031..0551a527 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -354,6 +354,13 @@ struct ContentView: View { showMessageActions = true } } + .onLongPressGesture(minimumDuration: 0.35) { + // Quick @mention: prefill input with @nick#abcd + if message.sender != "system" && message.sender != viewModel.nickname { + messageText = "@\(message.sender) " + isTextFieldFocused = true + } + } .padding(.horizontal, 12) .padding(.vertical, 2) } From d5e712e27f819652aeb46b5fa85d58e540ddb9f0 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 12:18:33 +0200 Subject: [PATCH 08/15] UX: remove long-press mention; add context menu 'Copy message' that copies only the message body (no nick/timestamp) --- bitchat/Views/ContentView.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 0551a527..caa276a8 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -354,11 +354,15 @@ struct ContentView: View { showMessageActions = true } } - .onLongPressGesture(minimumDuration: 0.35) { - // Quick @mention: prefill input with @nick#abcd - if message.sender != "system" && message.sender != viewModel.nickname { - messageText = "@\(message.sender) " - isTextFieldFocused = true + .contextMenu { + Button("Copy message") { + #if os(iOS) + UIPasteboard.general.string = message.content + #else + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(message.content, forType: .string) + #endif } } .padding(.horizontal, 12) From f8a955214b9c00b00d3959b3201f8a7c8d1a10c1 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 12:24:17 +0200 Subject: [PATCH 09/15] Geo teleported: robust tag detection (accept 't', 'teleport', and boolean-like values); mark self on send when tagging; should fix peer list icons --- bitchat/ViewModels/ChatViewModel.swift | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 519ef0df..b3a7befc 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1126,6 +1126,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { NostrRelayManager.shared.sendEvent(event) // Track ourselves as active participant self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI + if LocationChannelManager.shared.teleported { + let key = identity.publicKeyHex.lowercased() + self.teleportedGeo = self.teleportedGeo.union([key]) + } } catch { SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error) self.addSystemMessage("failed to send to location channel") @@ -1196,7 +1201,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if self.processedNostrEvents.contains(event.id) { return } self.recordProcessedEvent(event.id) // Track teleport tag for participants - if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, teleTag[1] == "teleport" { + // Detect teleport tag robustly: accept ["t","teleport"], ["t"], ["teleport"], or boolean-like values + let hasTeleportTag: Bool = { + for tag in event.tags { + guard let key = tag.first?.lowercased() else { continue } + if key == "t" || key == "teleport" { return true } + // Some clients may encode as ["t","1"] or ["t","true"] + if key == "t", tag.dropFirst().contains(where: { v in + let lv = v.lowercased() + return lv == "teleport" || lv == "1" || lv == "true" + }) { return true } + } + return false + }() + if hasTeleportTag { let key = event.pubkey.lowercased() Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) From ff5fd3f3fe07c18c3d71bbc4cd739bc2138d2987 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 12:29:23 +0200 Subject: [PATCH 10/15] Logs: add GeoTeleport diagnostics (incoming tags, self/peer marking, counts) to debug peer list dashed icon behavior --- bitchat/ViewModels/ChatViewModel.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index b3a7befc..fd4fe434 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1126,10 +1126,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate { NostrRelayManager.shared.sendEvent(event) // Track ourselves as active participant self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + 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 { 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)", + category: SecureLogger.session, level: .info) } } catch { SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error) @@ -1186,6 +1190,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if LocationChannelManager.shared.teleported { let key = id.publicKeyHex.lowercased() teleportedGeo = teleportedGeo.union([key]) + SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", + category: SecureLogger.session, level: .info) } #endif } @@ -1200,6 +1206,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Deduplicate if self.processedNostrEvents.contains(event.id) { return } self.recordProcessedEvent(event.id) + // Log incoming tags for diagnostics + let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") + SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", + category: SecureLogger.session, level: .debug) // Track teleport tag for participants // Detect teleport tag robustly: accept ["t","teleport"], ["t"], ["teleport"], or boolean-like values let hasTeleportTag: Bool = { @@ -1218,6 +1228,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { 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) } } // Skip our own events (we already locally echoed) From 16fdb7b49e8e554dff4eb7188ce1d19f6dbc45e3 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 12:34:44 +0200 Subject: [PATCH 11/15] Teleport persistence: store per-geohash teleported state in UserDefaults; initialize on startup; OR with location-derived status; sheet writes persisted flag on select/teleport --- bitchat/Services/LocationChannelManager.swift | 36 +++++++++++++++++-- bitchat/Views/LocationChannelsSheet.swift | 6 ++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index 5c76ff6a..322a21a8 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -21,6 +21,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa private var lastLocation: CLLocation? private var refreshTimer: Timer? private let userDefaultsKey = "locationChannel.selected" + private let teleportedStoreKey = "locationChannel.teleportedSet" private var isGeocoding: Bool = false // Published state for UI bindings @@ -31,6 +32,9 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa @Published var teleported: Bool = false @Published private(set) var locationNames: [GeohashChannelLevel: String] = [:] + // Persisted set of geohashes that were selected via teleport + private var teleportedSet: Set = [] + private override init() { super.init() cl.delegate = self @@ -41,6 +45,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { selectedChannel = channel } + // Load persisted teleported set + if let data = UserDefaults.standard.data(forKey: teleportedStoreKey), + 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) + } let status: CLAuthorizationStatus if #available(iOS 14.0, *) { status = cl.authorizationStatus @@ -104,6 +117,24 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa if let data = try? JSONEncoder().encode(channel) { UserDefaults.standard.set(data, forKey: self.userDefaultsKey) } + // Update teleported flag based on persisted state for immediate UI behavior + switch channel { + case .mesh: + self.teleported = false + case .location(let ch): + self.teleported = self.teleportedSet.contains(ch.geohash) + } + } + } + + // Mark or unmark a geohash as teleported in persistence and update current flag if relevant + func markTeleported(for geohash: String, _ flag: Bool) { + if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) } + if let data = try? JSONEncoder().encode(Array(teleportedSet)) { + UserDefaults.standard.set(data, forKey: teleportedStoreKey) + } + if case .location(let ch) = selectedChannel, ch.geohash == geohash { + Task { @MainActor in self.teleported = flag } } } @@ -164,13 +195,14 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa } Task { @MainActor in self.availableChannels = result - // Recompute teleported status based on current location vs selected channel + // Recompute teleported status based on persisted state OR current location vs selected channel 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 = (currentGH != ch.geohash) + self.teleported = persisted || (currentGH != ch.geohash) } } } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 84d4fd58..da88c0a2 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -107,8 +107,8 @@ struct LocationChannelsSheet: View { let subtitlePrefix = "#\(channel.geohash) • \(coverage)" let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) { - // Selecting a suggested nearby channel is not a teleport - manager.teleported = false + // Selecting a suggested nearby channel is not a teleport. Persist this. + manager.markTeleported(for: channel.geohash, false) manager.select(ChannelID.location(channel)) isPresented = false } @@ -153,7 +153,7 @@ struct LocationChannelsSheet: View { let level = levelForLength(gh.count) let ch = GeohashChannel(level: level, geohash: gh) // Mark this selection as a manual teleport - manager.teleported = true + manager.markTeleported(for: ch.geohash, true) manager.select(ChannelID.location(ch)) isPresented = false }) { From 4bfe9ac80d503b72668adc01f04c0431e2e246bb Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 12:51:40 +0200 Subject: [PATCH 12/15] Geohash count: use the same pruned/sorted list for toolbar and peer list (visibleGeohashPeople) to ensure consistency --- bitchat/ViewModels/ChatViewModel.swift | 11 +++++++++++ bitchat/Views/ContentView.swift | 6 +++++- bitchat/Views/GeohashPeopleList.swift | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index fd4fe434..c8be5787 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1473,6 +1473,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Public helpers (iOS) #if os(iOS) + /// Return the current, pruned, sorted people list for the active geohash without mutating state. + @MainActor + func visibleGeohashPeople() -> [GeoPerson] { + guard let gh = currentGeohash else { return [] } + let cutoff = Date().addingTimeInterval(-5 * 60) + let map = (geoParticipants[gh] ?? [:]).filter { $0.value >= cutoff } + let people = map + .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } + .sorted { $0.lastSeen > $1.lastSeen } + return people + } /// Returns the current participant count for a specific geohash, using the 5-minute activity window. @MainActor func geohashParticipantCount(for geohash: String) -> Int { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index caa276a8..8940663d 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -871,8 +871,12 @@ struct ContentView: View { // Unread icon immediately to the left of the channel badge (independent from channel button) #if os(iOS) let cc = channelPeopleCountAndColor() - let otherPeersCount = cc.0 + var otherPeersCount = cc.0 let countColor = cc.1 + // Ensure geohash count matches the actual pruned list used in the sidebar + if case .location = locationManager.selectedChannel { + otherPeersCount = viewModel.visibleGeohashPeople().count + } #else let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in guard peer.id != viewModel.meshService.myPeerID else { return } diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 69528c61..e5c5065c 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -24,7 +24,7 @@ struct GeohashPeopleList: View { } return nil }() - let ordered = viewModel.geohashPeople.sorted { a, b in + let ordered = viewModel.visibleGeohashPeople().sorted { a, b in if let me = myHex { if a.id == me && b.id != me { return true } if b.id == me && a.id != me { return false } From d4b779080a93f69e951593a68d5c0a32f8136ac3 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 12:58:08 +0200 Subject: [PATCH 13/15] Fix header string interpolation: simplify accessibilityLabel to avoid unterminated string; use '\(headerOtherPeersCount) people' --- bitchat/Views/ContentView.swift | 49 +++++++++++++++++---------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 8940663d..0487cd0a 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -867,28 +867,31 @@ struct ContentView: View { Spacer() // Channel badge + dynamic spacing + people counter + // Precompute header count and color outside the ViewBuilder expressions + #if os(iOS) + let cc = channelPeopleCountAndColor() + let headerCountColor: Color = cc.1 + let headerOtherPeersCount: Int = { + if case .location = locationManager.selectedChannel { + return viewModel.visibleGeohashPeople().count + } + return cc.0 + }() + #else + let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in + guard peer.id != viewModel.meshService.myPeerID else { return } + let isMeshConnected = peer.isConnected + if isMeshConnected { counts.mesh += 1; counts.others += 1 } + else if peer.isMutualFavorite { counts.others += 1 } + } + let headerOtherPeersCount = peerCounts.others + // Darker, more neutral blue (less purple hue) + let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) + let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary + #endif + HStack(spacing: 10) { // Unread icon immediately to the left of the channel badge (independent from channel button) - #if os(iOS) - let cc = channelPeopleCountAndColor() - var otherPeersCount = cc.0 - let countColor = cc.1 - // Ensure geohash count matches the actual pruned list used in the sidebar - if case .location = locationManager.selectedChannel { - otherPeersCount = viewModel.visibleGeohashPeople().count - } - #else - let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in - guard peer.id != viewModel.meshService.myPeerID else { return } - let isMeshConnected = peer.isConnected - if isMeshConnected { counts.mesh += 1; counts.others += 1 } - else if peer.isMutualFavorite { counts.others += 1 } - } - let otherPeersCount = peerCounts.others - // Darker, more neutral blue (less purple hue) - let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) - let countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary - #endif // Unread indicator #if os(iOS) @@ -932,12 +935,12 @@ struct ContentView: View { // People icon with count Image(systemName: "person.2.fill") .font(.system(size: 11)) - .accessibilityLabel("\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")") - Text("\(otherPeersCount)") + .accessibilityLabel("\(headerOtherPeersCount) people") + Text("\(headerOtherPeersCount)") .font(.system(size: 12, design: .monospaced)) .accessibilityHidden(true) } - .foregroundColor(countColor) + .foregroundColor(headerCountColor) } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { From 9d5105a8bf2e07ddc96be8b3587aea01583d8890 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 13:02:34 +0200 Subject: [PATCH 14/15] Geohash list: gate empty/render using visibleGeohashPeople() so header count and list visibility are consistent --- bitchat/Views/GeohashPeopleList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index e5c5065c..2227a78b 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -10,7 +10,7 @@ struct GeohashPeopleList: View { var body: some View { Group { - if viewModel.geohashPeople.isEmpty { + if viewModel.visibleGeohashPeople().isEmpty { Text("nobody around...") .font(.system(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) From 27e5e2962cc6346467e888969a240740fa988a4d Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 22 Aug 2025 13:04:56 +0200 Subject: [PATCH 15/15] Scroll: move bottom detection to View state and pass Binding to messagesView; autoscroll only when bound is true to prevent jumps when user scrolled up --- bitchat/Views/ContentView.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 0487cd0a..3590202f 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -77,6 +77,8 @@ struct ContentView: View { @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: String? @FocusState private var isNicknameFieldFocused: Bool + @State private var isAtBottomPublic: Bool = true + @State private var isAtBottomPrivate: Bool = true @State private var lastScrollTime: Date = .distantPast @State private var scrollThrottleTimer: Timer? @State private var autocompleteDebounceTimer: Timer? @@ -273,9 +275,8 @@ struct ContentView: View { // MARK: - Message List View - private func messagesView(privatePeer: String?) -> some View { + private func messagesView(privatePeer: String?, isAtBottom: Binding) -> some View { ScrollViewReader { proxy in - @State var isAtBottom: Bool = true ScrollView { LazyVStack(alignment: .leading, spacing: 0) { // Extract messages based on context (private or public chat) @@ -337,12 +338,12 @@ struct ContentView: View { .onAppear { // Track if last item is visible to enable auto-scroll only when near bottom if message.id == windowedMessages.last?.id { - isAtBottom = true + isAtBottom.wrappedValue = true } } .onDisappear { if message.id == windowedMessages.last?.id { - isAtBottom = false + isAtBottom.wrappedValue = false } } .contentShape(Rectangle()) @@ -379,7 +380,7 @@ struct ContentView: View { .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { // Only autoscroll when user is at/near bottom - guard isAtBottom else { return } + guard isAtBottom.wrappedValue else { return } // Throttle scroll animations to prevent excessive UI updates let now = Date() if now.timeIntervalSince(lastScrollTime) > 0.5 { @@ -401,7 +402,7 @@ struct ContentView: View { let messages = viewModel.privateChats[peerID], !messages.isEmpty { // Only autoscroll when user is at/near bottom - guard isAtBottom else { return } + guard isAtBottom.wrappedValue else { return } // Same throttling for private chats let now = Date() if now.timeIntervalSince(lastScrollTime) > 0.5 { @@ -727,7 +728,7 @@ struct ContentView: View { VStack(spacing: 0) { mainHeaderView Divider() - messagesView(privatePeer: nil) + messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic) Divider() inputView } @@ -777,7 +778,7 @@ struct ContentView: View { VStack(spacing: 0) { privateHeaderView Divider() - messagesView(privatePeer: viewModel.selectedPrivateChatPeer) + messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate) Divider() inputView }