From adffe7dfd6b5c962fd0ee4bf950edc84dc2746b3 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:41:26 +0200 Subject: [PATCH] Feature/peer colors (#476) * Feature: assign stable per-peer colors (non-self) across mesh/geohash/DM; keep self orange and mentions-to-me orange; cache colors * Fix compile warnings: remove unused primaryColor in formatMessageAsText; remove unused CryptoKit import; mark peerColor/formatMessageAsText @MainActor to call main-actor helpers * Fix: re-import CryptoKit for SHA256 usage in ChatViewModel * Peer lists: apply same per-peer colors as chat (self orange); suffix uses lighter variant; geohash uses Nostr pubkey, mesh uses Noise key * Fix warning: remove unused peerNicknames in MeshPeerList * UI: add 'mention' action in message actions to prefill input with @nick#abcd and focus input * UX: long-press a message to prefill @mention with sender's full nick#abcd and focus input * UX: remove long-press mention; add context menu 'Copy message' that copies only the message body (no nick/timestamp) * Geo teleported: robust tag detection (accept 't', 'teleport', and boolean-like values); mark self on send when tagging; should fix peer list icons * Logs: add GeoTeleport diagnostics (incoming tags, self/peer marking, counts) to debug peer list dashed icon behavior * Teleport persistence: store per-geohash teleported state in UserDefaults; initialize on startup; OR with location-derived status; sheet writes persisted flag on select/teleport --------- Co-authored-by: jack --- bitchat/Services/LocationChannelManager.swift | 36 ++++++- bitchat/ViewModels/ChatViewModel.swift | 96 ++++++++++++++++++- bitchat/Views/ContentView.swift | 19 ++++ bitchat/Views/GeohashPeopleList.swift | 6 +- bitchat/Views/LocationChannelsSheet.swift | 6 +- bitchat/Views/MeshPeerList.swift | 5 +- 6 files changed, 154 insertions(+), 14 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/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 1ebaf7ee..fd4fe434 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -79,8 +79,8 @@ import Foundation import SwiftUI -import Combine import CryptoKit +import Combine import CommonCrypto import CoreBluetooth #if os(iOS) @@ -1126,6 +1126,15 @@ 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) self.addSystemMessage("failed to send to location channel") @@ -1181,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 } @@ -1195,11 +1206,30 @@ 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 - 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]) + 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) @@ -2679,6 +2709,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,8 +2737,7 @@ 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 : 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 +2842,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 +3165,62 @@ 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 + } + + @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 + 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 + } + + @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_") { + 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) + } + + // 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/ContentView.swift b/bitchat/Views/ContentView.swift index 2cedae5a..caa276a8 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) @@ -346,6 +354,17 @@ struct ContentView: View { showMessageActions = 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) .padding(.vertical, 2) } 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/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 }) { diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 14ca6a1b..79941fc0 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 { @@ -17,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 @@ -61,7 +61,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))