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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-22 12:41:26 +02:00
committed by GitHub
co-authored by jack
parent 13f8b0c636
commit adffe7dfd6
6 changed files with 154 additions and 14 deletions
+34 -2
View File
@@ -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<String> = []
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)
}
}
}
+91 -5
View File
@@ -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
+19
View File
@@ -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)
}
+4 -2
View File
@@ -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)
+3 -3
View File
@@ -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
}) {
+3 -2
View File
@@ -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))