mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Refactor 1/n: ChatViewModel's Message Sending section (#613)
* Extract BitchatMessage into a separate file * Convert `fromBinaryPayload` to `convenience init?` * Extract message dedup into an extension * Remove dead `formatMessageContent` * Minor refactor of timestamp and username formatting * Remove dead `getSenderColor` * Extract MessagePadding into a separate file * Extract BitchatPacket into a separate file * Extract ReadReceipt into a separate file * Extract NoisePayload into a separate file * Remove unnecessary import * Extract peer-seed color calculation out * Extract `handleDeliveredReadReceipt` to a new function * Extract `handlePrivateMessage` to a new function * Separate `delivered` and `readReceipt` functions * Extract `handleGiftWrap` into a function * Extract `subscribeToGeoChat` into a function * Minor cleanup * Extract `handleNostrEvent` into a function * Minor cleanup * Create `sendGeohash` function + minor cleanup * Extract sending geohash dm into a function * Check for blocks before trying to send a DM
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Color+Peer.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
private static var peerColorCache: [String: Color] = [:]
|
||||
|
||||
init(peerSeed: String, isDark: Bool) {
|
||||
let cacheKey = peerSeed + (isDark ? "|dark" : "|light")
|
||||
if let cached = Self.peerColorCache[cacheKey] {
|
||||
self = cached
|
||||
}
|
||||
let h = peerSeed.djb2()
|
||||
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 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)
|
||||
Self.peerColorCache[cacheKey] = c
|
||||
self = c
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// String+DJB2.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func djb2() -> UInt64 {
|
||||
var hash: UInt64 = 5381
|
||||
for b in utf8 { hash = ((hash << 5) &+ hash) &+ UInt64(b) }
|
||||
return hash
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
let prefix = String(collapsed.prefix(TransportConfig.contentKeyPrefixLength))
|
||||
// Fast djb2 hash
|
||||
let h = djb2(prefix)
|
||||
let h = prefix.djb2()
|
||||
return String(format: "h:%016llx", h)
|
||||
}
|
||||
|
||||
@@ -1396,108 +1396,118 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
updatePrivateChatPeerIfNeeded()
|
||||
|
||||
if let selectedPeer = selectedPrivateChatPeer {
|
||||
// Send as private message
|
||||
sendPrivateMessage(content, to: selectedPeer)
|
||||
} else {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse mentions from the content (use original content for user intent)
|
||||
let mentions = parseMentions(from: content)
|
||||
|
||||
// Add message to local display
|
||||
var displaySender = nickname
|
||||
var localSenderPeerID = meshService.myPeerID
|
||||
if case .location(let ch) = activeChannel,
|
||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
|
||||
displaySender = nickname + "#" + suffix
|
||||
localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
sender: displaySender,
|
||||
content: trimmed,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
senderPeerID: localSenderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
// Add to main messages immediately for user feedback
|
||||
messages.append(message)
|
||||
|
||||
// Update content LRU for near-dup detection
|
||||
let ckey = normalizedContentKey(message.content)
|
||||
recordContentKey(ckey, timestamp: message.timestamp)
|
||||
|
||||
// Persist to channel-specific timelines
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(message)
|
||||
trimMeshTimelineIfNeeded()
|
||||
case .location(let ch):
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
arr.append(message)
|
||||
if arr.count > geoTimelineCap {
|
||||
arr = Array(arr.suffix(geoTimelineCap))
|
||||
}
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
trimMessagesIfNeeded()
|
||||
|
||||
// Force immediate UI update for user's own messages
|
||||
objectWillChange.send()
|
||||
|
||||
// Update channel activity time on send
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
lastPublicActivityAt["mesh"] = Date()
|
||||
case .location(let ch):
|
||||
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
||||
}
|
||||
|
||||
if case .location(let ch) = activeChannel {
|
||||
// Send to geohash channel via Nostr ephemeral
|
||||
Task { @MainActor in
|
||||
sendGeohash(ch: ch, content: trimmed)
|
||||
}
|
||||
} else {
|
||||
// Parse mentions from the content (use original content for user intent)
|
||||
let mentions = parseMentions(from: content)
|
||||
// Send via mesh with mentions
|
||||
meshService.sendMessage(content, mentions: mentions)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func sendGeohash(ch: GeohashChannel, content: String) {
|
||||
do {
|
||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
|
||||
// Add message to local display
|
||||
var displaySender = nickname
|
||||
var localSenderPeerID = meshService.myPeerID
|
||||
if case .location(let ch) = activeChannel,
|
||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
|
||||
displaySender = nickname + "#" + suffix
|
||||
localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
sender: displaySender,
|
||||
content: trimmed,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: localSenderPeerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: ch.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: nickname,
|
||||
teleported: LocationChannelManager.shared.teleported
|
||||
)
|
||||
|
||||
// Add to main messages immediately for user feedback
|
||||
messages.append(message)
|
||||
// Update content LRU for near-dup detection
|
||||
let ckey = normalizedContentKey(message.content)
|
||||
recordContentKey(ckey, timestamp: message.timestamp)
|
||||
// Persist to channel-specific timelines
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
meshTimeline.append(message)
|
||||
trimMeshTimelineIfNeeded()
|
||||
case .location(let ch):
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
arr.append(message)
|
||||
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||
geoTimelines[ch.geohash] = arr
|
||||
}
|
||||
trimMessagesIfNeeded()
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
|
||||
// Force immediate UI update for user's own messages
|
||||
objectWillChange.send()
|
||||
|
||||
// Update channel activity time on send
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
lastPublicActivityAt["mesh"] = Date()
|
||||
case .location(let ch):
|
||||
lastPublicActivityAt["geo:\(ch.geohash)"] = Date()
|
||||
}
|
||||
|
||||
if case .location(let ch) = activeChannel {
|
||||
// Send to geohash channel via Nostr ephemeral
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: trimmed,
|
||||
geohash: ch.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: self.nickname,
|
||||
teleported: LocationChannelManager.shared.teleported
|
||||
)
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: ch.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
if targetRelays.isEmpty {
|
||||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
// Track ourselves as active participant
|
||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session)
|
||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||
// 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.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send geohash message: \(error)", category: .session)
|
||||
self.addSystemMessage("failed to send to location channel")
|
||||
}
|
||||
}
|
||||
if targetRelays.isEmpty {
|
||||
SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session)
|
||||
} else {
|
||||
// Send via mesh with mentions
|
||||
meshService.sendMessage(content, mentions: mentions)
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
// Track ourselves as active participant
|
||||
recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session)
|
||||
|
||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||
// 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()
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send geohash message: \(error)", category: .session)
|
||||
addSystemMessage("failed to send to location channel")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1529,7 +1539,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
// If switching to a location channel, flush any pending geohash-only system messages
|
||||
if case .location = channel, !pendingGeohashSystemMessages.isEmpty {
|
||||
for m in pendingGeohashSystemMessages { addPublicSystemMessage(m) }
|
||||
for m in pendingGeohashSystemMessages {
|
||||
addPublicSystemMessage(m)
|
||||
}
|
||||
pendingGeohashSystemMessages.removeAll(keepingCapacity: false)
|
||||
}
|
||||
// Unsubscribe previous
|
||||
@@ -1548,9 +1560,10 @@ final 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 only when truly teleported
|
||||
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
||||
recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
@@ -1561,220 +1574,273 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
teleportedGeo.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
let subID = "geo-\(ch.geohash)"
|
||||
geoSubscriptionID = subID
|
||||
startGeoParticipantsTimer()
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
ch.geohash,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
||||
limit: TransportConfig.nostrGeohashInitialLimit
|
||||
)
|
||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
// Only handle ephemeral kind 20000 with matching tag
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
// 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.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
||||
let hasTeleportTag: Bool = event.tags.contains(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||
})
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
// 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.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||
if let gh = self.currentGeohash,
|
||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 { return }
|
||||
}
|
||||
// Cache nickname from tag if present
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
return
|
||||
}
|
||||
// Store mapping for geohash DM initiation
|
||||
let key16 = "nostr_" + String(event.pubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||
self.nostrKeyMapping[key16] = event.pubkey
|
||||
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
self.nostrKeyMapping[key8] = event.pubkey
|
||||
// Update participants last-seen for this pubkey
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
// If this is a teleport presence event (no content), don't add to timeline
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return
|
||||
}
|
||||
// Clamp future timestamps
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
let mentions = self.parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
Task { @MainActor in
|
||||
self.handlePublicMessage(msg)
|
||||
self.checkForMentions(msg)
|
||||
self.sendHapticFeedback(for: msg)
|
||||
}
|
||||
self?.handleNostrEvent(event)
|
||||
}
|
||||
|
||||
// Subscribe to Nostr gift wraps (DMs) for this geohash identity
|
||||
do {
|
||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
let dmSub = "geo-dm-\(ch.geohash)"
|
||||
geoDmSubscriptionID = dmSub
|
||||
// pared back logging: subscribe debug only
|
||||
// Log GeoDM subscribe only when Tor is ready to avoid early noise
|
||||
if TorManager.shared.isReady {
|
||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||
subscribeToGeoChat(ch)
|
||||
}
|
||||
|
||||
private func handleNostrEvent(_ event: NostrEvent) {
|
||||
// Only handle ephemeral kind 20000 with matching tag
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
|
||||
// Deduplicate
|
||||
if processedNostrEvents.contains(event.id) { return }
|
||||
recordProcessedEvent(event.id)
|
||||
|
||||
// Log incoming tags for diagnostics
|
||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||
|
||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||
}
|
||||
|
||||
let isSelf: Bool = {
|
||||
if let gh = currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||
}
|
||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
guard let self = self else { return }
|
||||
// Dedup basic
|
||||
if self.processedNostrEvents.contains(giftWrap.id) { return }
|
||||
self.recordProcessedEvent(giftWrap.id)
|
||||
// Decrypt with per-geohash identity
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
return
|
||||
return false
|
||||
}()
|
||||
|
||||
if hasTeleportTag {
|
||||
// Avoid marking our own key from historical events; rely on manager.teleported for self
|
||||
if !isSelf {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor in
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session)
|
||||
}
|
||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
||||
guard content.hasPrefix("bitchat1:") else { return }
|
||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData) else { return }
|
||||
guard packet.type == MessageType.noiseEncrypted.rawValue else { return }
|
||||
guard let noisePayload = NoisePayload.decode(packet.payload) else { return }
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let convKey = "nostr_" + String(senderPubkey.prefix(16))
|
||||
self.nostrKeyMapping[convKey] = senderPubkey
|
||||
switch noisePayload.type {
|
||||
case .privateMessage:
|
||||
guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
|
||||
// Send delivery ACK immediately (even if duplicate), once per messageID
|
||||
if !self.sentGeoDeliveryAcks.contains(messageId) {
|
||||
let nostrTransport = NostrTransport(keychain: keychain)
|
||||
nostrTransport.senderPeerID = self.meshService.myPeerID
|
||||
// pared back: omit pre-send log
|
||||
nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
|
||||
self.sentGeoDeliveryAcks.insert(messageId)
|
||||
}
|
||||
// Duplicate check
|
||||
if self.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
|
||||
for (_, arr) in self.privateChats { if arr.contains(where: { $0.id == messageId }) { return } }
|
||||
let senderName = self.displayNameForNostrPubkey(senderPubkey)
|
||||
let isViewing = (self.selectedPrivateChatPeer == convKey)
|
||||
let wasReadBefore = self.sentReadReceipts.contains(messageId)
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||
let msg = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderName,
|
||||
content: pm.content,
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: self.nickname,
|
||||
senderPeerID: convKey,
|
||||
mentions: nil,
|
||||
deliveryStatus: .delivered(to: self.nickname, at: Date())
|
||||
)
|
||||
if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] }
|
||||
self.privateChats[convKey]?.append(msg)
|
||||
self.trimPrivateChatMessagesIfNeeded(for: convKey)
|
||||
if shouldMarkUnread { self.unreadPrivateMessages.insert(convKey) }
|
||||
// Send READ if viewing this conversation
|
||||
if isViewing {
|
||||
// pared back: omit pre-send READ log
|
||||
if !wasReadBefore {
|
||||
let nostrTransport = NostrTransport(keychain: keychain)
|
||||
nostrTransport.senderPeerID = self.meshService.myPeerID
|
||||
nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
|
||||
self.sentReadReceipts.insert(messageId)
|
||||
}
|
||||
} else {
|
||||
// pared back: omit defer READ log
|
||||
}
|
||||
// Notify for truly unread and recent messages when not viewing
|
||||
if !isViewing && shouldMarkUnread {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderName,
|
||||
message: pm.content,
|
||||
peerID: convKey
|
||||
)
|
||||
}
|
||||
self.objectWillChange.send()
|
||||
default:
|
||||
// Handle delivered/read receipts for our sent messages
|
||||
switch noisePayload.type {
|
||||
case .delivered:
|
||||
if let messageID = String(data: noisePayload.data, encoding: .utf8) {
|
||||
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
||||
self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date())
|
||||
self.objectWillChange.send()
|
||||
SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
||||
} else {
|
||||
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||
}
|
||||
}
|
||||
case .readReceipt:
|
||||
if let messageID = String(data: noisePayload.data, encoding: .utf8) {
|
||||
if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
||||
self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date())
|
||||
self.objectWillChange.send()
|
||||
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
||||
} else {
|
||||
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||
if isSelf {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cache nickname from tag if present
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store mapping for geohash DM initiation
|
||||
let key16 = "nostr_" + String(event.pubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||
nostrKeyMapping[key16] = event.pubkey
|
||||
let key8 = "nostr:" + String(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||
nostrKeyMapping[key8] = event.pubkey
|
||||
|
||||
// Update participants last-seen for this pubkey
|
||||
recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
|
||||
// If this is a teleport presence event (no content), don't add to timeline
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
// Clamp future timestamps
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: min(rawTs, Date()),
|
||||
isRelay: false,
|
||||
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
handlePublicMessage(msg)
|
||||
checkForMentions(msg)
|
||||
sendHapticFeedback(for: msg)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func subscribeToGeoChat(_ ch: GeohashChannel) {
|
||||
guard let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
||||
|
||||
let dmSub = "geo-dm-\(ch.geohash)"
|
||||
geoDmSubscriptionID = dmSub
|
||||
// pared back logging: subscribe debug only
|
||||
// Log GeoDM subscribe only when Tor is ready to avoid early noise
|
||||
if TorManager.shared.isReady {
|
||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||
}
|
||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||
self?.handleGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
if processedNostrEvents.contains(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
recordProcessedEvent(giftWrap.id)
|
||||
|
||||
// Decrypt with per-geohash identity
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
|
||||
|
||||
guard content.hasPrefix("bitchat1:"),
|
||||
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
let packet = BitchatPacket.from(packetData),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let convKey = "nostr_" + String(senderPubkey.prefix(16))
|
||||
nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp)
|
||||
case .delivered:
|
||||
handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
|
||||
// Explicitly list other cases so we get compile-time check if a new case is added in the future
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
senderPubkey: String,
|
||||
convKey: String,
|
||||
id: NostrIdentity,
|
||||
messageTimestamp: Date
|
||||
) {
|
||||
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
|
||||
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
|
||||
|
||||
// Send delivery ACK immediately (even if duplicate), once per messageID
|
||||
if !sentGeoDeliveryAcks.contains(messageId) {
|
||||
let nostrTransport = NostrTransport(keychain: keychain)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
// pared back: omit pre-send log
|
||||
nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
|
||||
sentGeoDeliveryAcks.insert(messageId)
|
||||
}
|
||||
|
||||
// Duplicate check
|
||||
if privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
|
||||
for (_, arr) in privateChats {
|
||||
if arr.contains(where: { $0.id == messageId }) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(senderPubkey)
|
||||
let msg = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderName,
|
||||
content: pm.content,
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: nickname,
|
||||
senderPeerID: convKey,
|
||||
deliveryStatus: .delivered(to: nickname, at: Date())
|
||||
)
|
||||
|
||||
if privateChats[convKey] == nil {
|
||||
privateChats[convKey] = []
|
||||
}
|
||||
privateChats[convKey]?.append(msg)
|
||||
trimPrivateChatMessagesIfNeeded(for: convKey)
|
||||
|
||||
let isViewing = selectedPrivateChatPeer == convKey
|
||||
let wasReadBefore = sentReadReceipts.contains(messageId)
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||
if shouldMarkUnread {
|
||||
unreadPrivateMessages.insert(convKey)
|
||||
}
|
||||
|
||||
// Send READ if viewing this conversation
|
||||
if isViewing {
|
||||
// pared back: omit pre-send READ log
|
||||
if !wasReadBefore {
|
||||
let nostrTransport = NostrTransport(keychain: keychain)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
|
||||
sentReadReceipts.insert(messageId)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} else {
|
||||
// pared back: omit defer READ log
|
||||
}
|
||||
|
||||
// Notify for truly unread and recent messages when not viewing
|
||||
if !isViewing && shouldMarkUnread {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: senderName,
|
||||
message: pm.content,
|
||||
peerID: convKey
|
||||
)
|
||||
}
|
||||
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
private func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: String) {
|
||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||
|
||||
if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date())
|
||||
objectWillChange.send()
|
||||
SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
||||
} else {
|
||||
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: String) {
|
||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||
|
||||
if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date())
|
||||
objectWillChange.send()
|
||||
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
||||
} else {
|
||||
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||
}
|
||||
//
|
||||
}
|
||||
|
||||
// MARK: - Geohash Participants
|
||||
@@ -2081,72 +2147,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func sendPrivateMessage(_ content: String, to peerID: String) {
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
// Geohash DM routing: conversation keys start with "nostr_"
|
||||
if peerID.hasPrefix("nostr_") {
|
||||
guard case .location(let ch) = activeChannel else {
|
||||
addSystemMessage("cannot send: not in a location channel")
|
||||
return
|
||||
}
|
||||
let messageID = UUID().uuidString
|
||||
// Local echo in the DM thread
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: nickname,
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
||||
privateChats[peerID]?.append(message)
|
||||
trimPrivateChatMessagesIfNeeded(for: peerID)
|
||||
objectWillChange.send()
|
||||
|
||||
// Resolve recipient hex from mapping
|
||||
guard let recipientHex = nostrKeyMapping[peerID] else {
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "unknown recipient")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Respect geohash blocks
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "user is blocked")
|
||||
}
|
||||
addSystemMessage("cannot send message: user is blocked.")
|
||||
return
|
||||
}
|
||||
// Send via Nostr using per-geohash identity
|
||||
do {
|
||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
// Prevent messaging ourselves
|
||||
if recipientHex.lowercased() == id.publicKeyHex.lowercased() {
|
||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .failed(reason: "cannot message yourself")
|
||||
}
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
|
||||
let nostrTransport = NostrTransport(keychain: keychain)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .sent
|
||||
}
|
||||
} catch {
|
||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .failed(reason: "send error")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if blocked
|
||||
if unifiedPeerService.isBlocked(peerID) {
|
||||
let nickname = meshService.peerNickname(peerID: peerID) ?? "user"
|
||||
@@ -2154,6 +2154,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
// Geohash DM routing: conversation keys start with "nostr_"
|
||||
if peerID.hasPrefix("nostr_") {
|
||||
sendGeohashDM(content, to: peerID)
|
||||
}
|
||||
|
||||
// Determine routing method and recipient nickname
|
||||
guard let noiseKey = Data(hexString: peerID) else { return }
|
||||
let isConnected = meshService.isPeerConnected(peerID)
|
||||
@@ -2212,6 +2217,75 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
addSystemMessage("Cannot send message to \(recipientNickname ?? "user") - peer is not reachable via mesh or Nostr.")
|
||||
}
|
||||
}
|
||||
|
||||
private func sendGeohashDM(_ content: String, to peerID: String) {
|
||||
guard case .location(let ch) = activeChannel else {
|
||||
addSystemMessage("cannot send: not in a location channel")
|
||||
return
|
||||
}
|
||||
let messageID = UUID().uuidString
|
||||
|
||||
// Local echo in the DM thread
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: nickname,
|
||||
senderPeerID: meshService.myPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
|
||||
privateChats[peerID]?.append(message)
|
||||
trimPrivateChatMessagesIfNeeded(for: peerID)
|
||||
objectWillChange.send()
|
||||
|
||||
// Resolve recipient hex from mapping
|
||||
guard let recipientHex = nostrKeyMapping[peerID] else {
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "unknown recipient")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Respect geohash blocks
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "user is blocked")
|
||||
}
|
||||
addSystemMessage("cannot send message: user is blocked.")
|
||||
return
|
||||
}
|
||||
|
||||
// Send via Nostr using per-geohash identity
|
||||
do {
|
||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
// Prevent messaging ourselves
|
||||
if recipientHex.lowercased() == id.publicKeyHex.lowercased() {
|
||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .failed(reason: "cannot message yourself")
|
||||
}
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
|
||||
let nostrTransport = NostrTransport(keychain: keychain)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .sent
|
||||
}
|
||||
} catch {
|
||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .failed(reason: "send error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash DMs initiation
|
||||
@MainActor
|
||||
@@ -3697,38 +3771,6 @@ final 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 {
|
||||
let cacheKey = seed + (isDark ? "|dark" : "|light")
|
||||
if let cached = peerColorCache[cacheKey] { return cached }
|
||||
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 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
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
if let spid = message.senderPeerID {
|
||||
@@ -3748,7 +3790,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
// Fallback when we only have a display name
|
||||
return colorForPeerSeed(message.sender.lowercased(), isDark: isDark)
|
||||
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
||||
}
|
||||
|
||||
// Public helpers for views to color peers consistently in lists
|
||||
@@ -3797,8 +3839,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
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)
|
||||
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -3842,7 +3883,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
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)
|
||||
let h = (currentSeeds[id] ?? id).djb2()
|
||||
// Map to available slot range deterministically
|
||||
let idx = Int(h % UInt64(slots.count))
|
||||
return (id, idx)
|
||||
@@ -3937,7 +3978,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
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)
|
||||
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -3981,7 +4022,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
let peers = currentSeeds.keys.sorted()
|
||||
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||
let h = djb2(currentSeeds[id] ?? id)
|
||||
let h = (currentSeeds[id] ?? id).djb2()
|
||||
let idx = Int(h % UInt64(slots.count))
|
||||
return (id, idx)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user