Compare commits

..
16 Commits
48 changed files with 1443 additions and 1824 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License
@@ -22,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data
+18 -1
View File
@@ -87,7 +87,7 @@ import Foundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity {
let peerID: String // 8 random bytes
let peer: Peer // 8 random bytes
let sessionStart: Date
var handshakeState: HandshakeState
}
@@ -158,6 +158,23 @@ struct IdentityCache: Codable {
var version: Int = 1
}
// MARK: - Identity Resolution
enum IdentityHint {
case unknown
case likelyKnown(fingerprint: String)
case ambiguous(candidates: Set<String>)
case verified(fingerprint: String)
}
// MARK: - Pending Actions
struct PendingActions {
var toggleFavorite: Bool?
var setTrustLevel: TrustLevel?
var setPetname: String?
}
//
// MARK: - Migration Support
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
// MARK: Cryptographic Identities
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
func getCryptoIdentitiesByPeerIDPrefix(_ peer: Peer) -> [CryptographicIdentity]
func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
func getBlockedNostrPubkeys() -> Set<String>
// MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
func updateHandshakeState(peerID: String, state: HandshakeState)
func registerEphemeralSession(peer: Peer, handshakeState: HandshakeState)
func updateHandshakeState(peer: Peer, state: HandshakeState)
// MARK: Cleanup
func clearAllIdentityData()
func removeEphemeralSession(peerID: String)
func removeEphemeralSession(peer: Peer)
// MARK: Verification
func setVerified(fingerprint: String, verified: Bool)
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
private let encryptionKeyName = "identityCacheEncryptionKey"
// In-memory state
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
private var ephemeralSessions: [Peer: EphemeralIdentity] = [:]
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
private var cache: IdentityCache = IdentityCache()
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
func getCryptoIdentitiesByPeerIDPrefix(_ peer: Peer) -> [CryptographicIdentity] {
queue.sync {
// Defensive: ensure hex and correct length
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
guard peer.isShort, peer.id.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peer.id) }
}
}
@@ -455,19 +455,19 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
func registerEphemeralSession(peer: Peer, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID,
self.ephemeralSessions[peer] = EphemeralIdentity(
peer: peer,
sessionStart: Date(),
handshakeState: handshakeState
)
}
}
func updateHandshakeState(peerID: String, state: HandshakeState) {
func updateHandshakeState(peer: Peer, state: HandshakeState) {
queue.async(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state
self.ephemeralSessions[peer]?.handshakeState = state
// If handshake completed, update last interaction
if case .completed(let fingerprint) = state {
@@ -493,9 +493,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
func removeEphemeralSession(peerID: String) {
func removeEphemeralSession(peer: Peer) {
queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID)
self.ephemeralSessions.removeValue(forKey: peer)
}
}
+23 -10
View File
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: String?
let senderPeer: Peer?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
@@ -39,10 +39,23 @@ final class BitchatMessage: Codable {
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
case isPrivate, recipientNickname, mentions, deliveryStatus
case senderPeer = "senderPeerID" // backwards compatibility
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
init(
id: String? = nil,
sender: String,
content: String,
timestamp: Date,
isRelay: Bool,
originalSender: String? = nil,
isPrivate: Bool = false,
recipientNickname: String? = nil,
senderPeer: Peer? = nil,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
@@ -51,7 +64,7 @@ final class BitchatMessage: Codable {
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.senderPeer = senderPeer
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
@@ -69,7 +82,7 @@ extension BitchatMessage: Equatable {
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.senderPeer == rhs.senderPeer &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
@@ -101,7 +114,7 @@ extension BitchatMessage {
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if senderPeer != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
@@ -151,7 +164,7 @@ extension BitchatMessage {
data.append(recipData.prefix(255))
}
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
if let peerData = senderPeer?.data {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
@@ -264,11 +277,11 @@ extension BitchatMessage {
}
}
var senderPeerID: String?
var senderPeer: Peer?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
senderPeer = Peer(data: dataCopy[offset..<offset+length])
offset += length
}
}
@@ -302,7 +315,7 @@ extension BitchatMessage {
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
senderPeer: senderPeer,
mentions: mentions
)
}
+139
View File
@@ -0,0 +1,139 @@
//
// Peer.swift
// BitLogger
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import struct CryptoKit.SHA256
struct Peer: Equatable, Hashable {
let id: String
}
extension Peer {
var data: Data? {
id.data(using: .utf8)
}
var isNostr: Bool {
id.hasPrefix("nostr")
}
var isNostrColon: Bool {
id.hasPrefix("nostr:")
}
}
// MARK: - Validation
extension Peer {
private enum Constants {
static let maxIDLength = 64
static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool {
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
if isShort || isNoiseKeyHex {
return true
}
// If length equals short or full but isn't valid hex, reject
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
return false
}
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !id.isEmpty &&
id.count < Constants.maxIDLength &&
id.rangeOfCharacter(from: validCharset.inverted) == nil
}
/// Short routing IDs (exact 16-hex)
var isShort: Bool {
id.count == Constants.hexIDLength && Data(hexString: id) != nil
}
/// Full Noise key hex (exact 64-hex)
var isNoiseKeyHex: Bool {
noiseKey != nil
}
/// Full Noise key (exact 64-hex) as Data
var noiseKey: Data? {
guard id.count == Constants.maxIDLength else { return nil }
return Data(hexString: id)
}
}
// MARK: - ExpressibleByStringLiteral
extension Peer: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
self.init(str: value)
}
}
// MARK: - ExpressibleByStringInterpolation
extension Peer: ExpressibleByStringInterpolation {
init(extendedGraphemeClusterLiteral value: String) {
self.init(str: value)
}
}
// MARK: - Codable
extension Peer: Codable {
init(from decoder: any Decoder) throws {
id = try decoder.singleValueContainer().decode(String.self)
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(id)
}
}
// MARK: - Convenience Inits
extension Peer {
init(str: String) {
id = str.lowercased()
}
init(str: String.SubSequence) {
self.init(str: String(str))
}
init?(data: Data) {
guard let str = String(data: data, encoding: .utf8) else {
return nil
}
self.init(str: str)
}
}
// MARK: - Noise Public Key Helpers
extension Peer {
/// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
self.init(str: hex.prefix(16))
}
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
func toShort() -> Peer {
if id.count == Constants.maxIDLength, let data = Data(hexString: id) {
return Peer(publicKey: data)
}
return self
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ struct ReadReceipt: Codable {
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString()
guard InputValidator.validatePeerID(readerID) else { return nil }
guard Peer(str: readerID).isValid else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
@@ -0,0 +1,358 @@
//
// NoiseHandshakeCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
final class NoiseHandshakeCoordinator {
// MARK: - Handshake State
enum HandshakeState: Equatable {
case idle
case waitingToInitiate(since: Date)
case initiating(attempt: Int, lastAttempt: Date)
case responding(since: Date)
case waitingForResponse(messagesSent: [Data], timeout: Date)
case established(since: Date)
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
var isActive: Bool {
switch self {
case .idle, .established, .failed:
return false
default:
return true
}
}
}
// MARK: - Properties
private var handshakeStates: [String: HandshakeState] = [:]
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
// Configuration
private let maxHandshakeAttempts = 3
private let handshakeTimeout: TimeInterval = 10.0
private let retryDelay: TimeInterval = 2.0
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
private let maxEstablishedSessions = 50 // Limit total established sessions
// Track handshake messages to detect duplicates
private var processedHandshakeMessages: Set<Data> = []
private let messageHistoryLimit = 100
// MARK: - Role Determination
/// Deterministically determine who should initiate the handshake
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
// Use simple string comparison for deterministic ordering
return myPeerID < remotePeerID ? .initiator : .responder
}
/// Check if we should initiate handshake with a peer
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
return handshakeQueue.sync {
// Check if we're already in an active handshake
if let state = handshakeStates[remotePeerID], state.isActive {
// Check if the handshake is stale and we should force a new one
if forceIfStale {
switch state {
case .initiating(_, let lastAttempt):
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
return true
}
default:
break
}
}
SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
return false
}
// Check role
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
if role != .initiator {
return false
}
// Check if we've failed recently and can't retry yet
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
if !canRetry {
return false
}
if Date().timeIntervalSince(lastAttempt) < retryDelay {
return false
}
}
return true
}
}
/// Record that we're initiating a handshake
func recordHandshakeInitiation(peerID: String) {
handshakeQueue.async(flags: .barrier) {
let attempt = self.getCurrentAttempt(for: peerID) + 1
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
}
}
/// Record that we're responding to a handshake
func recordHandshakeResponse(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .responding(since: Date())
SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
}
}
/// Record successful handshake completion
func recordHandshakeSuccess(peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates[peerID] = .established(since: Date())
SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
}
}
/// Record handshake failure
func recordHandshakeFailure(peerID: String, reason: String) {
handshakeQueue.async(flags: .barrier) {
let attempts = self.getCurrentAttempt(for: peerID)
let canRetry = attempts < self.maxHandshakeAttempts
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
}
}
/// Check if we should accept an incoming handshake initiation
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
return handshakeQueue.sync {
// If we're already established, reject new handshakes
if case .established = handshakeStates[remotePeerID] {
SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
return false
}
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
// If we're the initiator and already initiating, this is a race condition
if role == .initiator {
if case .initiating = handshakeStates[remotePeerID] {
// They shouldn't be initiating, but accept it to recover from race condition
SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
return true
}
}
// If we're the responder, we should accept
return true
}
}
/// Check if this is a duplicate handshake message
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
return handshakeQueue.sync {
if processedHandshakeMessages.contains(data) {
return true
}
// Add to processed messages with size limit
if processedHandshakeMessages.count >= messageHistoryLimit {
processedHandshakeMessages.removeAll()
}
processedHandshakeMessages.insert(data)
return false
}
}
/// Get time to wait before next handshake attempt
func getRetryDelay(for peerID: String) -> TimeInterval? {
return handshakeQueue.sync {
guard let state = handshakeStates[peerID] else { return nil }
switch state {
case .failed(_, let canRetry, let lastAttempt):
if !canRetry { return nil }
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
if timeSinceFailure >= retryDelay {
return 0
}
return retryDelay - timeSinceFailure
case .initiating(_, let lastAttempt):
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
if timeSinceAttempt >= minTimeBetweenHandshakes {
return 0
}
return minTimeBetweenHandshakes - timeSinceAttempt
default:
return nil
}
}
}
/// Reset handshake state for a peer
func resetHandshakeState(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
self.handshakeStates.removeValue(forKey: peerID)
SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
}
}
/// Clean up stale handshake states and old established sessions
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
return handshakeQueue.sync {
let now = Date()
var stalePeerIDs: [String] = []
var establishedSessions: [(peerID: String, since: Date)] = []
for (peerID, state) in handshakeStates {
var isStale = false
switch state {
case .initiating(_, let lastAttempt):
if now.timeIntervalSince(lastAttempt) > staleTimeout {
isStale = true
}
case .responding(let since):
if now.timeIntervalSince(since) > staleTimeout {
isStale = true
}
case .waitingForResponse(_, let timeout):
if now > timeout {
isStale = true
}
case .established(let since):
// Track established sessions for potential cleanup
establishedSessions.append((peerID, since))
// Clean up very old established sessions
if now.timeIntervalSince(since) > establishedSessionTTL {
isStale = true
}
default:
break
}
if isStale {
stalePeerIDs.append(peerID)
SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
}
}
// If we have too many established sessions, clean up the oldest ones
if establishedSessions.count > maxEstablishedSessions {
// Sort by age (oldest first)
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
for i in 0..<sessionsToRemove {
let peerID = sortedSessions[i].peerID
stalePeerIDs.append(peerID)
SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
}
}
// Clean up stale states
for peerID in stalePeerIDs {
handshakeStates.removeValue(forKey: peerID)
}
if !stalePeerIDs.isEmpty {
SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
}
return stalePeerIDs
}
}
/// Get current handshake state
func getHandshakeState(for peerID: String) -> HandshakeState {
return handshakeQueue.sync {
return handshakeStates[peerID] ?? .idle
}
}
/// Get current retry count for a peer
func getRetryCount(for peerID: String) -> Int {
return handshakeQueue.sync {
switch handshakeStates[peerID] {
case .initiating(let attempt, _):
return attempt - 1 // Attempts start at 1, retries start at 0
default:
return 0
}
}
}
/// Increment retry count for a peer
func incrementRetryCount(for peerID: String) {
handshakeQueue.async(flags: .barrier) {
let currentAttempt = self.getCurrentAttempt(for: peerID)
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
}
}
// MARK: - Private Helpers
private func getCurrentAttempt(for peerID: String) -> Int {
switch handshakeStates[peerID] {
case .initiating(let attempt, _):
return attempt
case .failed(_, _, _):
// Count previous attempts
return 1 // Simplified for now
default:
return 0
}
}
/// Log current handshake states for debugging
func logHandshakeStates() {
handshakeQueue.sync {
SecureLogger.debug("=== Handshake States ===", category: .handshake)
for (peerID, state) in handshakeStates {
let stateDesc: String
switch state {
case .idle:
stateDesc = "idle"
case .waitingToInitiate(let since):
stateDesc = "waiting to initiate (since \(since))"
case .initiating(let attempt, let lastAttempt):
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
case .responding(let since):
stateDesc = "responding (since: \(since))"
case .waitingForResponse(let messages, let timeout):
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
case .established(let since):
stateDesc = "established (since \(since))"
case .failed(let reason, let canRetry, let lastAttempt):
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
}
SecureLogger.debug(" \(peerID): \(stateDesc)", category: .handshake)
}
SecureLogger.debug("========================", category: .handshake)
}
}
/// Clear all handshake states - used during panic mode
func clearAllHandshakeStates() {
handshakeQueue.async(flags: .barrier) {
SecureLogger.warning("Clearing all handshake states for panic mode", category: .handshake)
self.handshakeStates.removeAll()
self.processedHandshakeMessages.removeAll()
}
}
}
+13 -18
View File
@@ -53,11 +53,6 @@ struct NoiseSecurityValidator {
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
/// Validate peer ID format using unified validator
static func validatePeerID(_ peerID: String) -> Bool {
return InputValidator.validatePeerID(peerID)
}
}
// MARK: - Enhanced Noise Session with Security
@@ -137,8 +132,8 @@ final class SecureNoiseSession: NoiseSession {
// MARK: - Rate Limiter
final class NoiseRateLimiter {
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var handshakeTimestamps: [Peer: [Date]] = [:] // Peer -> timestamps
private var messageTimestamps: [Peer: [Date]] = [:] // Peer -> timestamps
// Global rate limiting
private var globalHandshakeTimestamps: [Date] = []
@@ -146,7 +141,7 @@ final class NoiseRateLimiter {
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peerID: String) -> Bool {
func allowHandshake(from peer: Peer) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
@@ -159,23 +154,23 @@ final class NoiseRateLimiter {
}
// Check per-peer rate limit
var timestamps = handshakeTimestamps[peerID] ?? []
var timestamps = handshakeTimestamps[peer] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peer.id): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false
}
// Record new handshake
timestamps.append(now)
handshakeTimestamps[peerID] = timestamps
handshakeTimestamps[peer] = timestamps
globalHandshakeTimestamps.append(now)
return true
}
}
func allowMessage(from peerID: String) -> Bool {
func allowMessage(from peer: Peer) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
@@ -188,26 +183,26 @@ final class NoiseRateLimiter {
}
// Check per-peer rate limit
var timestamps = messageTimestamps[peerID] ?? []
var timestamps = messageTimestamps[peer] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
SecureLogger.warning("Per-peer message rate limit exceeded for \(peer.id): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false
}
// Record new message
timestamps.append(now)
messageTimestamps[peerID] = timestamps
messageTimestamps[peer] = timestamps
globalMessageTimestamps.append(now)
return true
}
}
func reset(for peerID: String) {
func reset(for peer: Peer) {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID)
self.handshakeTimestamps.removeValue(forKey: peer)
self.messageTimestamps.removeValue(forKey: peer)
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
if let maybeData = Data(hexString: recipientPeerID) {
if maybeData.count == 32 {
// Treat as Noise static public key; derive peerID from fingerprint
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
return Peer(publicKey: maybeData).id
} else if maybeData.count == 8 {
// Already an 8-byte peer ID
return recipientPeerID
+10 -23
View File
@@ -41,8 +41,6 @@ final class NostrRelayManager: ObservableObject {
@Published private(set) var isConnected = false
private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
@@ -84,27 +82,17 @@ final class NostrRelayManager: ObservableObject {
private var connectionGeneration: Int = 0
init() {
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
applyDefaultRelayPolicy(force: true)
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
allowDefaultRelays = hasMutual
if hasMutual {
self.relays = Self.defaultRelays.map { Relay(url: $0) }
}
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
}
.store(in: &cancellables)
}
@@ -338,11 +326,10 @@ final class NostrRelayManager: ObservableObject {
}
}
private func applyDefaultRelayPolicy(force: Bool = false) {
let shouldAllow = hasMutualFavorites || hasLocationPermission
if !force && shouldAllow == allowDefaultRelays { return }
allowDefaultRelays = shouldAllow
if shouldAllow {
private func updateDefaultRelayPolicy(hasMutual: Bool) {
guard hasMutual != allowDefaultRelays else { return }
allowDefaultRelays = hasMutual
if hasMutual {
var existing = Set(relays.map { $0.url })
for url in Self.defaultRelays where !existing.contains(url) {
relays.append(Relay(url: url))
-14
View File
@@ -1,14 +0,0 @@
import Foundation
import CryptoKit
// MARK: - Peer ID Utilities
struct PeerIDUtils {
/// Derive the stable 16-hex peer ID from a Noise static public key
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
return String(hex.prefix(16))
}
}
+59 -173
View File
@@ -7,78 +7,6 @@ import CryptoKit
import UIKit
#endif
struct NotificationStreamAssembler {
private var buffer = Data()
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
guard !chunk.isEmpty else { return ([], [], false) }
buffer.append(chunk)
var frames: [Data] = []
var dropped: [UInt8] = []
var reset = false
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
while buffer.count >= minFramePrefix {
guard let first = buffer.first else { break }
if first != 1 {
dropped.append(buffer.removeFirst())
continue
}
guard buffer.count >= minHeaderBytes else { break }
let headerBytes = Array(buffer.prefix(minFramePrefix))
guard headerBytes.count == minFramePrefix else { break }
let flags = headerBytes[11]
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
var frameLength = minFramePrefix + payloadLen
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
guard frameLength > 0, frameLength <= maxFrameLength else {
buffer.removeAll()
reset = true
break
}
if buffer.count < frameLength {
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
if dropCount > 0 {
buffer.removeFirst(dropCount)
dropped.append(1) // treat as dropped partial start
}
}
break
}
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
buffer.removeAll(keepingCapacity: false)
}
return (frames, dropped, reset)
}
mutating func reset() {
buffer.removeAll(keepingCapacity: false)
}
}
/// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -112,7 +40,6 @@ final class BLEService: NSObject {
var isConnecting: Bool = false
var isConnected: Bool = false
var lastConnectionAttempt: Date? = nil
var assembler = NotificationStreamAssembler()
}
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
@@ -264,9 +191,7 @@ final class BLEService: NSObject {
// MARK: - Helpers: IDs, selection, and write backpressure
private func makeMessageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString()
let digest = SHA256.hash(data: packet.payload)
let digestPrefix = digest.prefix(4).map { String(format: "%02x", $0) }.joined()
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
}
private func subsetSizeForFanout(_ n: Int) -> Int {
@@ -631,23 +556,13 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
}()
let shortID = Peer(str: peerID).toShort().id
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
}
func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
}()
let shortID = Peer(str: peerID).toShort().id
return collectionsQueue.sync {
// Must be mesh-attached: at least one live direct link to the mesh
let meshAttached = peers.values.contains { $0.isConnected }
@@ -701,10 +616,11 @@ final class BLEService: NSObject {
var payload = Data([NoisePayloadType.readReceipt.rawValue])
payload.append(contentsOf: receipt.originalMessageID.utf8)
if noiseService.hasEstablishedSession(with: peerID) {
let peer = Peer(str: peerID)
if noiseService.hasEstablishedSession(with: peer) {
SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
let encrypted = try noiseService.encrypt(payload, for: peer)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
@@ -728,7 +644,7 @@ final class BLEService: NSObject {
guard let self = self else { return }
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
}
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
if !noiseService.hasSession(with: peer) { initiateNoiseHandshake(with: peerID) }
SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
}
}
@@ -749,13 +665,13 @@ final class BLEService: NSObject {
}
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
guard noiseService.hasSession(with: peerID) else {
guard noiseService.hasSession(with: Peer(str: peerID)) else {
// Lazy-handshake path: queue? For now, initiate handshake and drop
initiateNoiseHandshake(with: peerID)
return
}
do {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let encrypted = try noiseService.encrypt(typedPayload, for: Peer(str: peerID))
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
@@ -790,9 +706,9 @@ final class BLEService: NSObject {
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState {
if noiseService.hasEstablishedSession(with: peerID) {
if noiseService.hasEstablishedSession(with: Peer(str: peerID)) {
return .established
} else if noiseService.hasSession(with: peerID) {
} else if noiseService.hasSession(with: Peer(str: peerID)) {
return .handshaking
} else {
return .none
@@ -923,7 +839,7 @@ final class BLEService: NSObject {
SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session)
// Check if we have an established Noise session
if noiseService.hasEstablishedSession(with: recipientID) {
if noiseService.hasEstablishedSession(with: Peer(str: recipientID)) {
// Encrypt and send
do {
// Create TLV-encoded private message
@@ -937,7 +853,7 @@ final class BLEService: NSObject {
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
messagePayload.append(tlvData)
let encrypted = try noiseService.encrypt(messagePayload, for: recipientID)
let encrypted = try noiseService.encrypt(messagePayload, for: Peer(str: recipientID))
// Convert recipientID to Data (assuming it's a hex string)
var recipientData = Data()
@@ -1003,10 +919,10 @@ final class BLEService: NSObject {
private func initiateNoiseHandshake(with peerID: String) {
// Use NoiseEncryptionService for handshake
guard !noiseService.hasSession(with: peerID) else { return }
guard !noiseService.hasSession(with: Peer(str: peerID)) else { return }
do {
let handshakeData = try noiseService.initiateHandshake(with: peerID)
let handshakeData = try noiseService.initiateHandshake(with: Peer(str: peerID))
// Send handshake init
let packet = BitchatPacket(
@@ -1056,7 +972,7 @@ final class BLEService: NSObject {
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
messagePayload.append(tlvData)
let encrypted = try noiseService.encrypt(messagePayload, for: peerID)
let encrypted = try noiseService.encrypt(messagePayload, for: Peer(str: peerID))
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
@@ -1185,13 +1101,6 @@ final class BLEService: NSObject {
// Determine last-hop link for this message to avoid echoing back
let messageID = makeMessageID(for: packet)
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
let directedPeerHint: String? = {
if let explicit = directedOnlyPeer { return explicit }
if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty {
return recipient
}
return nil
}()
let states = snapshotPeripheralStates()
var minCentralWriteLen: Int?
@@ -1245,7 +1154,7 @@ final class BLEService: NSObject {
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs)
if directedPeerHint == nil
if directedOnlyPeer == nil
&& packet.type != MessageType.fragment.rawValue
&& packet.type != MessageType.announce.rawValue
&& packet.type != MessageType.requestSync.rawValue {
@@ -1256,7 +1165,7 @@ final class BLEService: NSObject {
}
// If directed and we currently have no links to forward on, spool for a short window
if let only = directedPeerHint,
if let only = directedOnlyPeer,
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
spoolDirectedPacket(packet, recipientPeerID: only)
@@ -1600,7 +1509,7 @@ final class BLEService: NSObject {
// Verify that the sender's derived ID from the announced noise public key matches the packet senderID
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey)
let derivedFromKey = Peer(publicKey: announcement.noisePublicKey).id
if derivedFromKey != peerID {
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: .security)
@@ -1812,7 +1721,7 @@ final class BLEService: NSObject {
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
// Find candidate identities by peerID prefix (16 hex)
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(Peer(str: peerID))
for candidate in candidates {
if let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
@@ -1877,7 +1786,7 @@ final class BLEService: NSObject {
recipientID.hexEncodedString() == myPeerID {
// Handshake is for us
do {
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
if let response = try noiseService.processHandshakeMessage(from: Peer(str: peerID), message: packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
@@ -1897,7 +1806,7 @@ final class BLEService: NSObject {
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !noiseService.hasSession(with: peerID) {
if !noiseService.hasSession(with: Peer(str: peerID)) {
initiateNoiseHandshake(with: peerID)
}
}
@@ -1922,7 +1831,7 @@ final class BLEService: NSObject {
updatePeerLastSeen(peerID)
do {
let decrypted = try noiseService.decrypt(packet.payload, from: peerID)
let decrypted = try noiseService.decrypt(packet.payload, from: Peer(str: peerID))
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
@@ -1962,7 +1871,7 @@ final class BLEService: NSObject {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake")
if !noiseService.hasSession(with: peerID) {
if !noiseService.hasSession(with: Peer(str: peerID)) {
initiateNoiseHandshake(with: peerID)
}
} catch {
@@ -2067,9 +1976,9 @@ final class BLEService: NSObject {
var payload = Data([NoisePayloadType.delivered.rawValue])
payload.append(contentsOf: messageID.utf8)
if noiseService.hasEstablishedSession(with: peerID) {
if noiseService.hasEstablishedSession(with: Peer(str: peerID)) {
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
let encrypted = try noiseService.encrypt(payload, for: Peer(str: peerID))
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
@@ -2089,7 +1998,7 @@ final class BLEService: NSObject {
guard let self = self else { return }
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
}
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
if !noiseService.hasSession(with: Peer(str: peerID)) { initiateNoiseHandshake(with: peerID) }
SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session)
}
}
@@ -2104,7 +2013,7 @@ final class BLEService: NSObject {
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session)
for payload in payloads {
do {
let encrypted = try noiseService.encrypt(payload, for: peerID)
let encrypted = try noiseService.encrypt(payload, for: Peer(str: peerID))
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData,
@@ -2597,8 +2506,7 @@ extension BLEService: CBCentralManagerDelegate {
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: Date(),
assembler: NotificationStreamAssembler()
lastConnectionAttempt: Date()
)
peripheral.delegate = self
@@ -2647,9 +2555,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
isConnected: true
)
}
@@ -2787,8 +2693,7 @@ extension BLEService {
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: Date(),
assembler: NotificationStreamAssembler()
lastConnectionAttempt: Date()
)
peripheral.delegate = self
let options: [String: Any] = [
@@ -2925,72 +2830,53 @@ extension BLEService: CBPeripheralDelegate {
return
}
guard let data = characteristic.value, !data.isEmpty else {
guard let data = characteristic.value else {
SecureLogger.warning("⚠️ No data in notification", category: .session)
return
}
bufferNotificationChunk(data, from: peripheral)
}
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
let peripheralUUID = peripheral.identifier.uuidString
var state = peripherals[peripheralUUID] ?? PeripheralState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: peripheral.state == .connected,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
)
var assembler = state.assembler
let result = assembler.append(chunk)
state.assembler = assembler
peripherals[peripheralUUID] = state
for byte in result.droppedPrefixes {
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
// Received BLE notification
// Process directly on main thread to avoid deadlocks (matches original implementation)
guard let packet = BinaryProtocol.decode(data) else {
// Avoid dumping entire payload; log size and short prefix for diagnostics
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))", category: .session)
return
}
if result.reset {
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
}
for frame in result.frames {
guard let packet = BinaryProtocol.decode(frame) else {
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue
}
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
}
}
private func processNotificationPacket(_ packet: BitchatPacket, from peripheral: CBPeripheral, peripheralUUID: String) {
// Use the packet's senderID as the peer identifier
let senderID = packet.senderID.hexEncodedString()
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
}
// Only log non-announce packets
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
}
let peripheralUUID = peripheral.identifier.uuidString
// Update mapping ONLY for announce packets that come directly from the peer (not relayed)
if packet.type == MessageType.announce.rawValue {
// Only update mapping if this is a direct announce (TTL == messageTTL means not relayed)
if packet.ttl == messageTTL {
if var state = peripherals[peripheralUUID] {
state.peerID = senderID
peripherals[peripheralUUID] = state
}
peerToPeripheralUUID[senderID] = peripheralUUID
// Mapping update - direct announce from peer
}
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
}
// Process the announce packet regardless of whether we updated the mapping
handleReceivedPacket(packet, from: senderID)
} else {
// For non-announce packets, DO NOT update mappings
// These could be relayed packets from other peers
// Always use the packet's original senderID
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
@@ -179,12 +179,11 @@ final class FavoritesPersistenceService: ObservableObject {
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
/// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes)
guard peerID.count == 16 else { return nil }
for (pubkey, rel) in favorites {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel }
func getFavoriteStatus(for peer: Peer) -> FavoriteRelationship? {
// Quick sanity: peer.id should be 16 hex chars (8 bytes)
guard peer.isShort else { return nil }
for (pubkey, rel) in favorites where Peer(publicKey: pubkey) == peer {
return rel
}
return nil
}
+7 -53
View File
@@ -1,34 +1,5 @@
import BitLogger
import Foundation
struct LocationNotesCounterDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
static let live = LocationNotesCounterDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
}
)
}
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
@MainActor
final class LocationNotesCounter: ObservableObject {
@@ -37,45 +8,29 @@ final class LocationNotesCounter: ObservableObject {
@Published private(set) var geohash: String? = nil
@Published private(set) var count: Int? = 0
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var relayAvailable: Bool = true
private var subscriptionID: String? = nil
private var noteIDs = Set<String>()
private let dependencies: LocationNotesCounterDependencies
private init(dependencies: LocationNotesCounterDependencies = .live) {
self.dependencies = dependencies
}
init(testDependencies: LocationNotesCounterDependencies) {
self.dependencies = testDependencies
}
private init() {}
func subscribe(geohash gh: String) {
let norm = gh.lowercased()
if geohash == norm, subscriptionID != nil { return }
// Unsubscribe previous without clearing count to avoid flicker
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil
geohash = norm
noteIDs.removeAll()
initialLoadComplete = false
relayAvailable = true
// Subscribe only to the building geohash (precision 8)
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
relayAvailable = false
initialLoadComplete = true
count = 0
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
return
}
subscriptionID = subID
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
let relayUrls: [String]? = relays.isEmpty ? nil : relays
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
@@ -83,17 +38,16 @@ final class LocationNotesCounter: ObservableObject {
self.noteIDs.insert(event.id)
self.count = self.noteIDs.count
}
}, { [weak self] in
}, onEOSE: { [weak self] in
self?.initialLoadComplete = true
})
}
func cancel() {
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
subscriptionID = nil
geohash = nil
count = 0
noteIDs.removeAll()
relayAvailable = true
}
}
+14 -115
View File
@@ -1,57 +1,10 @@
import BitLogger
import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
struct LocationNotesDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
},
sendEvent: { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
},
deriveIdentity: { geohash in
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() }
)
}
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
/// Subscribes to and publishes notes for a given geohash and provides a send API.
@MainActor
final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case ready
case noRelays
}
struct Note: Identifiable, Equatable {
let id: String
let pubkey: String
@@ -71,14 +24,10 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
@Published private(set) var geohash: String
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String?
private var subscriptionID: String?
private let dependencies: LocationNotesDependencies
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
init(geohash: String) {
self.geohash = geohash.lowercased()
self.dependencies = dependencies
subscribe()
}
@@ -86,7 +35,7 @@ final class LocationNotesManager: ObservableObject {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
NostrRelayManager.shared.unsubscribe(id: sub)
subscriptionID = nil
}
geohash = norm
@@ -94,43 +43,15 @@ final class LocationNotesManager: ObservableObject {
subscribe()
}
func refresh() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
notes.removeAll()
subscribe()
}
func clearError() {
errorMessage = nil
}
private func subscribe() {
state = .loading
errorMessage = nil
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
subscriptionID = nil
initialLoadComplete = true
state = .noRelays
errorMessage = "No geo relays available near this location. Try again soon."
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
return
}
subscriptionID = subID
initialLoadComplete = false
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
let relayUrls: [String]? = relays.isEmpty ? nil : relays
initialLoadComplete = false
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag
@@ -141,13 +62,8 @@ final class LocationNotesManager: ObservableObject {
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.state = .ready
}, { [weak self] in
guard let self = self else { return }
self.initialLoadComplete = true
if self.state != .noRelays {
self.state = .ready
}
}, onEOSE: { [weak self] in
self?.initialLoadComplete = true
})
}
@@ -155,46 +71,29 @@ final class LocationNotesManager: ObservableObject {
func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = "No geo relays available near this location. Try again soon."
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
return
}
do {
let id = try dependencies.deriveIdentity(geohash)
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname
)
dependencies.sendEvent(event, relays)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
NostrRelayManager.shared.sendEvent(event, to: relays)
// Optimistic local-echo
let echo = Note(
id: event.id,
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: dependencies.now(),
nickname: nickname
)
let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname)
self.notes.insert(echo, at: 0)
self.state = .ready
self.errorMessage = nil
} catch {
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
errorMessage = "Failed to send note. \(error.localizedDescription)"
}
}
/// Explicitly cancel subscription and release resources.
func cancel() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
NostrRelayManager.shared.unsubscribe(id: sub)
subscriptionID = nil
}
state = .idle
errorMessage = nil
}
}
+45 -45
View File
@@ -6,7 +6,7 @@ import Foundation
final class MessageRouter {
private let mesh: Transport
private let nostr: NostrTransport
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
private var outbox: [Peer: [(content: String, nickname: String, messageID: String)]] = [:] // Peer -> queued messages
init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh
@@ -21,80 +21,80 @@ final class MessageRouter {
) { [weak self] note in
guard let self = self else { return }
if let data = note.userInfo?["peerPublicKey"] as? Data {
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
let peer = Peer(publicKey: data)
Task { @MainActor in
self.flushOutbox(for: peerID)
self.flushOutbox(for: peer)
}
}
// Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
let peer = Peer(publicKey: newKey)
Task { @MainActor in
self.flushOutbox(for: peerID)
self.flushOutbox(for: peer)
}
}
}
}
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID)
func sendPrivate(_ content: String, to peer: Peer, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peer.id)
if reachableMesh {
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
SecureLogger.debug("Routing PM via mesh (reachable) to \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
mesh.sendPrivateMessage(content, to: peer.id, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peer: peer) {
SecureLogger.debug("Routing PM via Nostr to \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peer.id, recipientNickname: recipientNickname, messageID: messageID)
} else {
// Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
if outbox[peer] == nil { outbox[peer] = [] }
outbox[peer]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peer.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
func sendReadReceipt(_ receipt: ReadReceipt, to peer: Peer) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
mesh.sendReadReceipt(receipt, to: peerID)
if mesh.isPeerReachable(peer.id) {
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peer.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
mesh.sendReadReceipt(receipt, to: peer.id)
} else {
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
nostr.sendReadReceipt(receipt, to: peerID)
SecureLogger.debug("Routing READ ack via Nostr to \(peer.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
nostr.sendReadReceipt(receipt, to: peer.id)
}
}
func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendDeliveryAck(for: messageID, to: peerID)
func sendDeliveryAck(_ messageID: String, to peer: Peer) {
if mesh.isPeerReachable(peer.id) {
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendDeliveryAck(for: messageID, to: peer.id)
} else {
nostr.sendDeliveryAck(for: messageID, to: peerID)
nostr.sendDeliveryAck(for: messageID, to: peer.id)
}
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
func sendFavoriteNotification(to peer: Peer, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
if mesh.isPeerConnected(peer.id) {
mesh.sendFavoriteNotification(to: peer.id, isFavorite: isFavorite)
} else {
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
nostr.sendFavoriteNotification(to: peer.id, isFavorite: isFavorite)
}
}
// MARK: - Outbox Management
private func canSendViaNostr(peerID: String) -> Bool {
private func canSendViaNostr(peer: Peer) -> Bool {
// Two forms are supported:
// - 64-hex Noise public key (32 bytes)
// - 16-hex short peer ID (derived from Noise pubkey)
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
if let noiseKey = peer.noiseKey {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil {
return true
}
} else if peerID.count == 16 {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
} else if peer.isShort {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer),
fav.peerNostrPublicKey != nil {
return true
}
@@ -102,18 +102,18 @@ final class MessageRouter {
return false
}
func flushOutbox(for peerID: String) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
func flushOutbox(for peer: Peer) {
guard let queued = outbox[peer], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peer.id.prefix(8))… count=\(queued.count)", category: .session)
var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
if mesh.isPeerReachable(peer.id) {
SecureLogger.debug("Outbox -> mesh for \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
mesh.sendPrivateMessage(content, to: peer.id, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peer: peer) {
SecureLogger.debug("Outbox -> Nostr for \(peer.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
nostr.sendPrivateMessage(content, to: peer.id, recipientNickname: nickname, messageID: messageID)
} else {
// Keep unsent items queued
remaining.append((content, nickname, messageID))
@@ -121,9 +121,9 @@ final class MessageRouter {
}
// Persist only items we could not send
if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
outbox.removeValue(forKey: peer)
} else {
outbox[peerID] = remaining
outbox[peer] = remaining
}
}
+49 -48
View File
@@ -62,6 +62,7 @@
/// ## Integration Points
/// - **BLEService**: Calls this service for all private messages
/// - **ChatViewModel**: Monitors encryption status for UI indicators
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
/// - **KeychainManager**: Secure storage for identity keys
///
/// ## Thread Safety
@@ -147,8 +148,8 @@ final class NoiseEncryptionService {
private let sessionManager: NoiseSessionManager
// Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
private var peerFingerprints: [Peer: String] = [:] // Peer -> fingerprint
private var fingerprintToPeer: [String: Peer] = [:] // fingerprint -> Peer
// Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
@@ -236,7 +237,7 @@ final class NoiseEncryptionService {
// Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
self?.handleSessionEstablished(peer: Peer(str: peerID), remoteStaticKey: remoteStaticKey)
}
// Start session maintenance timer
@@ -262,8 +263,8 @@ final class NoiseEncryptionService {
}
/// Get peer's public key data
func getPeerPublicKeyData(_ peerID: String) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
func getPeerPublicKeyData(_ peer: Peer) -> Data? {
return sessionManager.getRemoteStaticKey(for: peer.id)?.rawRepresentation
}
/// Clear persistent identity (for panic mode)
@@ -390,52 +391,52 @@ final class NoiseEncryptionService {
// MARK: - Handshake Management
/// Initiate a Noise handshake with a peer
func initiateHandshake(with peerID: String) throws -> Data {
func initiateHandshake(with peer: Peer) throws -> Data {
// Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.warning(.authenticationFailed(peerID: peerID))
guard peer.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peer.id))
throw NoiseSecurityError.invalidPeerID
}
// Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
guard rateLimiter.allowHandshake(from: peer) else {
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peer.id)"))
throw NoiseSecurityError.rateLimitExceeded
}
SecureLogger.info(.handshakeStarted(peerID: peerID))
SecureLogger.info(.handshakeStarted(peerID: peer.id))
// Return raw handshake data without wrapper
// The Noise protocol handles its own message format
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
let handshakeData = try sessionManager.initiateHandshake(with: peer.id)
return handshakeData
}
/// Process an incoming handshake message
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
func processHandshakeMessage(from peer: Peer, message: Data) throws -> Data? {
// Validate peer ID
guard NoiseSecurityValidator.validatePeerID(peerID) else {
SecureLogger.warning(.authenticationFailed(peerID: peerID))
guard peer.isValid else {
SecureLogger.warning(.authenticationFailed(peerID: peer.id))
throw NoiseSecurityError.invalidPeerID
}
// Validate message size
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
SecureLogger.warning(.handshakeFailed(peerID: peer.id, error: "Message too large"))
throw NoiseSecurityError.messageTooLarge
}
// Check rate limit
guard rateLimiter.allowHandshake(from: peerID) else {
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
guard rateLimiter.allowHandshake(from: peer) else {
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peer.id)"))
throw NoiseSecurityError.rateLimitExceeded
}
// For handshakes, we process the raw data directly without NoiseMessage wrapper
// The Noise protocol handles its own message format
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
let responsePayload = try sessionManager.handleIncomingHandshake(from: peer.id, message: message)
// Return raw response without wrapper
@@ -443,117 +444,117 @@ final class NoiseEncryptionService {
}
/// Check if we have an established session with a peer
func hasEstablishedSession(with peerID: String) -> Bool {
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
func hasEstablishedSession(with peer: Peer) -> Bool {
return sessionManager.getSession(for: peer.id)?.isEstablished() ?? false
}
/// Check if we have a session (established or handshaking) with a peer
func hasSession(with peerID: String) -> Bool {
return sessionManager.getSession(for: peerID) != nil
func hasSession(with peer: Peer) -> Bool {
return sessionManager.getSession(for: peer.id) != nil
}
// MARK: - Encryption/Decryption
/// Encrypt data for a specific peer
func encrypt(_ data: Data, for peerID: String) throws -> Data {
func encrypt(_ data: Data, for peer: Peer) throws -> Data {
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
// Check rate limit
guard rateLimiter.allowMessage(from: peerID) else {
guard rateLimiter.allowMessage(from: peer) else {
throw NoiseSecurityError.rateLimitExceeded
}
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
guard hasEstablishedSession(with: peer) else {
// Signal that handshake is needed
onHandshakeRequired?(peerID)
onHandshakeRequired?(peer.id)
throw NoiseEncryptionError.handshakeRequired
}
return try sessionManager.encrypt(data, for: peerID)
return try sessionManager.encrypt(data, for: peer.id)
}
/// Decrypt data from a specific peer
func decrypt(_ data: Data, from peerID: String) throws -> Data {
func decrypt(_ data: Data, from peer: Peer) throws -> Data {
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge
}
// Check rate limit
guard rateLimiter.allowMessage(from: peerID) else {
guard rateLimiter.allowMessage(from: peer) else {
throw NoiseSecurityError.rateLimitExceeded
}
// Check if we have an established session
guard hasEstablishedSession(with: peerID) else {
guard hasEstablishedSession(with: peer) else {
throw NoiseEncryptionError.sessionNotEstablished
}
return try sessionManager.decrypt(data, from: peerID)
return try sessionManager.decrypt(data, from: peer.id)
}
// MARK: - Peer Management
/// Get fingerprint for a peer
func getPeerFingerprint(_ peerID: String) -> String? {
func getPeerFingerprint(_ peer: Peer) -> String? {
return serviceQueue.sync {
return peerFingerprints[peerID]
return peerFingerprints[peer]
}
}
/// Get peer ID for a fingerprint
func getPeerID(for fingerprint: String) -> String? {
func getPeer(for fingerprint: String) -> Peer? {
return serviceQueue.sync {
return fingerprintToPeerID[fingerprint]
return fingerprintToPeer[fingerprint]
}
}
/// Remove a peer session
func removePeer(_ peerID: String) {
sessionManager.removeSession(for: peerID)
func removePeer(_ peer: Peer) {
sessionManager.removeSession(for: peer.id)
serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints[peerID] {
fingerprintToPeerID.removeValue(forKey: fingerprint)
if let fingerprint = peerFingerprints[peer] {
fingerprintToPeer.removeValue(forKey: fingerprint)
}
peerFingerprints.removeValue(forKey: peerID)
peerFingerprints.removeValue(forKey: peer)
}
SecureLogger.info(.sessionExpired(peerID: peerID))
SecureLogger.info(.sessionExpired(peerID: peer.id))
}
func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) {
peerFingerprints.removeAll()
fingerprintToPeerID.removeAll()
fingerprintToPeer.removeAll()
}
rateLimiter.resetAll()
}
// MARK: - Private Helpers
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
private func handleSessionEstablished(peer: Peer, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
// Calculate fingerprint
let fingerprint = calculateFingerprint(for: remoteStaticKey)
// Store fingerprint mapping
serviceQueue.sync(flags: .barrier) {
peerFingerprints[peerID] = fingerprint
fingerprintToPeerID[fingerprint] = peerID
peerFingerprints[peer] = fingerprint
fingerprintToPeer[fingerprint] = peer
}
// Log security event
SecureLogger.info(.handshakeCompleted(peerID: peerID))
SecureLogger.info(.handshakeCompleted(peerID: peer.id))
// Notify all handlers about authentication
serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint)
handler(peer.id, fingerprint)
}
}
}
+1 -1
View File
@@ -174,7 +174,7 @@ final class NostrTransport: Transport {
return npub
}
if peerID.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Peer(str: peerID)),
let npub = fav.peerNostrPublicKey {
return npub
}
+2 -2
View File
@@ -61,11 +61,11 @@ final class NotificationService {
sendLocalNotification(title: title, body: body, identifier: identifier)
}
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
func sendPrivateMessageNotification(from sender: String, message: String, peer: Peer) {
let title = "🔒 DM from \(sender)"
let body = message
let identifier = "private-\(UUID().uuidString)"
let userInfo = ["peerID": peerID, "senderName": sender]
let userInfo = ["peerID": peer.id, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
}
+6 -6
View File
@@ -73,7 +73,7 @@ final class PrivateChatManager: ObservableObject {
originalSender: nil,
isPrivate: true,
recipientNickname: peerNickname,
senderPeerID: meshService.myPeerID,
senderPeer: Peer(str: meshService.myPeerID),
mentions: nil,
deliveryStatus: .sending
)
@@ -94,7 +94,7 @@ final class PrivateChatManager: ObservableObject {
/// Handle incoming private message
func handleIncomingMessage(_ message: BitchatMessage) {
guard let senderPeerID = message.senderPeerID else { return }
guard let senderPeerID = message.senderPeer?.id else { return }
// Initialize chat if needed
if privateChats[senderPeerID] == nil {
@@ -126,7 +126,7 @@ final class PrivateChatManager: ObservableObject {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: senderPeerID
peer: Peer(str: senderPeerID)
)
}
} else {
@@ -161,7 +161,7 @@ final class PrivateChatManager: ObservableObject {
// Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] {
for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
if message.senderPeer?.id == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
}
@@ -214,7 +214,7 @@ final class PrivateChatManager: ObservableObject {
private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeerID else {
let senderPeerID = message.senderPeer?.id else {
return
}
@@ -231,7 +231,7 @@ final class PrivateChatManager: ObservableObject {
if let router = messageRouter {
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
Task { @MainActor in
router.sendReadReceipt(receipt, to: senderPeerID)
router.sendReadReceipt(receipt, to: Peer(str: senderPeerID))
}
} else {
// Fallback: preserve previous behavior
+23 -16
View File
@@ -18,17 +18,13 @@ struct RelayController {
isAnnounce: Bool,
degree: Int,
highDegreeThreshold: Int) -> RelayDecision {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
// Suppress obvious non-relays
if ttlCap <= 1 || senderIsSelf {
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
}
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
// For session-critical or directed traffic, be deterministic and reliable
if isHandshake || isDirectedFragment || isDirectedEncrypted {
// Always relay with no TTL cap for these types
let newTTL = ttlCap &- 1
let newTTL = (ttl &- 1)
// Slight jitter to desynchronize without adding too much latency
// Tighter for faster multi-hop handshakes and directed DMs
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
@@ -36,17 +32,28 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
let baseProb: Double
switch degree {
case 0...2: baseProb = 1.0
case 3...4: baseProb = 0.9
case 5...6: baseProb = 0.7
case 7...9: baseProb = 0.55
default: baseProb = 0.45
}
let prob = baseProb
let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping for broadcast
// - Dense graphs: keep lower but still allow multi-hop bridging
// - Announces get a bit more headroom
let ttlLimit: UInt8 = {
if degree >= highDegreeThreshold {
return max(UInt8(2), min(ttlCap, UInt8(5)))
}
let preferred = UInt8(isAnnounce ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred))
// - Dense graphs: keep very low to avoid floods
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
// - Announces in sparse graphs get a bit more headroom
let ttlCap: UInt8 = {
if degree >= highDegreeThreshold { return 3 }
return isAnnounce ? 7 : 6
}()
let newTTL = ttlLimit &- 1
let clamped = max(1, min(ttl, ttlCap))
let newTTL = clamped &- 1
// Wider jitter window to allow duplicate suppression to win more often
// For sparse graphs (<=2), relay quickly to avoid cancellation races
@@ -57,6 +64,6 @@ struct RelayController {
case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220)
}
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
}
}
+1 -1
View File
@@ -307,7 +307,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Send favorite notification to the peer via router (mesh or Nostr)
if let router = messageRouter {
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
router.sendFavoriteNotification(to: Peer(str: peerID), isFavorite: !wasFavorite)
} else {
// Fallback to mesh-only if router not yet wired
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
+16 -70
View File
@@ -4,7 +4,7 @@ import CryptoKit
// Golomb-Coded Set (GCS) filter utilities for sync.
// Hashing:
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
// - Map to [0, M) via (h64 % M).
// Encoding (v1):
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
@@ -29,40 +29,25 @@ enum GCSFilter {
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
let p = deriveP(targetFpr: targetFpr)
guard !ids.isEmpty else {
return Params(p: p, m: 1, data: Data())
}
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
let selected = Array(ids.prefix(cap))
let range = max(1, hashRange(count: selected.count, p: p))
let modulo = UInt64(range)
var mapped = selected
.map { h64($0) }
.map { mapHash($0, modulo: modulo) }
.sorted()
mapped = normalizeMappedValues(mapped, modulo: modulo)
if mapped.isEmpty {
return Params(p: p, m: range, data: Data())
}
let n = min(ids.count, cap)
let selected = Array(ids.prefix(n))
// Map to [0, M)
let mInit = UInt32(n << p)
var mapped = selected.map { id16 -> UInt64 in
let h = h64(id16)
return UInt64(h % UInt64(max(1, mInit)))
}.sorted()
var encoded = encode(sorted: mapped, p: p)
var trimmedCount = mapped.count
while encoded.count > maxBytes && trimmedCount > 0 {
if trimmedCount == 1 {
mapped.removeAll()
encoded = Data()
break
}
trimmedCount = max(1, (trimmedCount * 9) / 10)
mapped = Array(mapped.prefix(trimmedCount))
var trimmedN = n
// Trim if over budget
while encoded.count > maxBytes && trimmedN > 0 {
trimmedN = (trimmedN * 9) / 10 // drop ~10%
mapped = Array(mapped.prefix(trimmedN))
encoded = encode(sorted: mapped, p: p)
}
return Params(p: p, m: range, data: encoded)
let finalM = UInt32(max(1, trimmedN << p))
return Params(p: p, m: finalM, data: encoded)
}
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
@@ -92,12 +77,6 @@ enum GCSFilter {
return false
}
static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {
let modulo = UInt64(max(1, m))
guard modulo > 1 else { return 0 }
return mapHash(h64(id), modulo: modulo)
}
private static func h64(_ id16: Data) -> UInt64 {
var hasher = SHA256()
hasher.update(data: id16)
@@ -109,39 +88,6 @@ enum GCSFilter {
return x & 0x7fff_ffff_ffff_ffff
}
private static func hashRange(count: Int, p: Int) -> UInt32 {
guard count > 0 else { return 1 }
if p >= 64 { return UInt32.max }
let multiplier = UInt64(1) << UInt64(p)
let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)
if overflow { return UInt32.max }
if product == 0 { return 1 }
return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)
}
private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {
guard modulo > 1 else { return 0 }
let value = hash % modulo
if value == 0 { return 1 }
return value
}
private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {
guard modulo > 1 else { return [] }
guard !values.isEmpty else { return [] }
var result: [UInt64] = []
result.reserveCapacity(values.count)
var last: UInt64 = 0
for value in values {
let normalized = min(value, modulo - 1)
if normalized > last {
result.append(normalized)
last = normalized
}
}
return result
}
private static func encode(sorted: [UInt64], p: Int) -> Data {
let writer = BitWriter()
var prev: UInt64 = 0
+11 -21
View File
@@ -1,4 +1,5 @@
import Foundation
import CryptoKit
// Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager {
@@ -52,12 +53,6 @@ final class GossipSyncManager {
}
func onPublicPacketSeen(_ packet: BitchatPacket) {
queue.async { [weak self] in
self?._onPublicPacketSeen(packet)
}
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
@@ -124,17 +119,18 @@ final class GossipSyncManager {
}
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
queue.async { [weak self] in
self?._handleRequestSync(fromPeerID: fromPeerID, request: request)
}
}
private func _handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
let bucket = GCSFilter.bucket(for: id, modulus: request.m)
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
var hasher = SHA256()
hasher.update(data: id) // 16-byte PacketId
let digest = hasher.finalize()
let db = Data(digest)
var x: UInt64 = 0
let take = min(8, db.count)
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
let v = (x & 0x7fff_ffff_ffff_ffff) % UInt64(request.m)
return GCSFilter.contains(sortedValues: sorted, candidate: v)
}
// 1) Announcements: send latest per peer if requester lacks them
@@ -186,15 +182,9 @@ final class GossipSyncManager {
// Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: String) {
queue.async { [weak self] in
self?._removeAnnouncementForPeer(peerID)
}
}
private func _removeAnnouncementForPeer(_ peerID: String) {
let normalizedPeerID = peerID.lowercased()
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
-37
View File
@@ -1,37 +0,0 @@
import SwiftUI
/// Provides Dynamic Type aware font helpers that map existing fixed sizes onto
/// preferred text styles so the UI scales with user accessibility settings.
extension Font {
static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font {
let style = Font.TextStyle.bitchatPreferredStyle(for: size)
var font = Font.system(style, design: design)
if weight != .regular {
font = font.weight(weight)
}
return font
}
}
private extension Font.TextStyle {
static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle {
switch size {
case ..<11.5:
return .caption2
case ..<13.0:
return .caption
case ..<14.0:
return .footnote
case ..<16.5:
return .body
case ..<19.0:
return .title3
case ..<23.0:
return .title2
case ..<30.0:
return .title
default:
return .largeTitle
}
}
}
+1 -24
View File
@@ -8,31 +8,8 @@ struct InputValidator {
struct Limits {
static let maxNicknameLength = 50
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
// for headers/padding by limiting user content to 60_000 bytes.
static let maxMessageLength = 60_000
static let maxMessageLength = 10_000
static let maxReasonLength = 200
static let maxPeerIDLength = 64
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
}
// MARK: - Peer ID Validation
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
static func validatePeerID(_ peerID: String) -> Bool {
// Accept short routing IDs (exact 16-hex)
if PeerIDResolver.isShortID(peerID) { return true }
// If length equals short-hex length but isn't valid hex, reject
if peerID.count == Limits.hexPeerIDLength { return false }
// Accept full Noise key hex (exact 64-hex)
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
// If length equals full key length but isn't valid hex, reject
if peerID.count == Limits.maxPeerIDLength { return false }
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !peerID.isEmpty &&
peerID.count < Limits.maxPeerIDLength &&
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
}
// MARK: - String Content Validation
-20
View File
@@ -1,20 +0,0 @@
import Foundation
struct PeerIDResolver {
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
static func toShortID(_ id: String) -> String {
if id.count == 64, let data = Data(hexString: id) {
return PeerIDUtils.derivePeerID(fromPublicKey: data)
}
return id
}
static func isShortID(_ id: String) -> Bool {
return id.count == 16 && Data(hexString: id) != nil
}
static func isNoiseKeyHex(_ id: String) -> Bool {
return id.count == 64 && Data(hexString: id) != nil
}
}
+89 -89
View File
@@ -153,7 +153,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func normalizedSenderKey(for message: BitchatMessage) -> String {
if let spid = message.senderPeerID {
if let spid = message.senderPeer?.id {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = {
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
@@ -310,7 +310,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
// Fallback: derive from active Noise session if available
if shortPeerID.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let key = meshService.getNoiseService().getPeerPublicKeyData(Peer(str: shortPeerID)) {
let stable = key.hexEncodedString()
shortIDToNoiseKey[shortPeerID] = stable
return stable
@@ -997,7 +997,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
senderPeer: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor in
@@ -1068,7 +1068,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: convKey,
senderPeer: Peer(str: convKey),
deliveryStatus: .delivered(to: nickname, at: Date())
)
@@ -1096,7 +1096,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
peer: Peer(str: convKey)
)
}
}
@@ -1231,7 +1231,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// If no nickname in status, try to get from private chat messages
if nickname == nil, let messages = privateChats[peerID], !messages.isEmpty {
// Get the nickname from the first message where this peer was the sender
nickname = messages.first { $0.senderPeerID == peerID }?.sender
nickname = messages.first { $0.senderPeer?.id == peerID }?.sender
}
let finalNickname = nickname ?? "Unknown"
@@ -1298,7 +1298,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Update identity state manager with handshake completion
identityManager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprintStr))
identityManager.updateHandshakeState(peer: Peer(str: peerID), state: .completed(fingerprint: fingerprintStr))
// Update encryption status now that we have the fingerprint
updateEncryptionStatus(for: peerID)
@@ -1468,7 +1468,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
content: trimmed,
timestamp: Date(),
isRelay: false,
senderPeerID: localSenderPeerID,
senderPeer: Peer(str: localSenderPeerID),
mentions: mentions.isEmpty ? nil : mentions
)
@@ -1719,7 +1719,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
content: content,
timestamp: min(rawTs, Date()),
isRelay: false,
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
senderPeer: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions
)
@@ -1819,7 +1819,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: convKey,
senderPeer: Peer(str: convKey),
deliveryStatus: .delivered(to: nickname, at: Date())
)
@@ -1850,7 +1850,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
peer: Peer(str: convKey)
)
}
@@ -2010,7 +2010,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let gh = currentGeohash {
if var arr = geoTimelines[gh] {
arr.removeAll { msg in
if let spid = msg.senderPeerID, spid.hasPrefix("nostr") {
if let spid = msg.senderPeer?.id, msg.senderPeer?.isNostr == true {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
}
return false
@@ -2021,7 +2021,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
switch activeChannel {
case .location:
messages.removeAll { msg in
if let spid = msg.senderPeerID, spid.hasPrefix("nostr") {
if let spid = msg.senderPeer?.id, msg.senderPeer?.isNostr == true {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
}
return false
@@ -2158,7 +2158,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
content: content,
timestamp: ts,
isRelay: false,
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
senderPeer: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
mentions: mentions.isEmpty ? nil : mentions
)
if !arr.contains(where: { $0.id == msg.id }) {
@@ -2267,7 +2267,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: true,
recipientNickname: recipientNickname,
senderPeerID: meshService.myPeerID,
senderPeer: Peer(str: meshService.myPeerID),
mentions: nil,
deliveryStatus: .sending
)
@@ -2284,7 +2284,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
messageRouter.sendPrivate(content, to: Peer(str: peerID), recipientNickname: recipientNickname ?? "user", messageID: messageID)
// Optimistically mark as sent for both transports; delivery/read will update subsequently
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .sent
@@ -2314,7 +2314,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: meshService.myPeerID,
senderPeer: Peer(str: meshService.myPeerID),
deliveryStatus: .sending
)
@@ -2403,7 +2403,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: true,
recipientNickname: meshService.peerNickname(peerID: peerID),
senderPeerID: meshService.myPeerID
senderPeer: Peer(str: meshService.myPeerID)
)
if privateChats[peerID] == nil { privateChats[peerID] = [] }
privateChats[peerID]?.append(systemMessage)
@@ -2501,7 +2501,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, // Update peer ID if it's from them
senderPeer: message.senderPeer?.id == meshService.myPeerID ? Peer(str: meshService.myPeerID) : Peer(str: peerID), // Update peer ID if it's from them
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
@@ -2512,7 +2512,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// 1. Not a message we sent
// 2. Message is recent (< 60s old)
// Never mark old messages as unread during consolidation
if message.senderPeerID != meshService.myPeerID {
if message.senderPeer?.id != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !sentReadReceipts.contains(message.id) {
hasActualUnreadMessages = true
@@ -2589,7 +2589,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID, // Update to match current peer
senderPeer: Peer(str: peerID), // Update to match current peer
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
@@ -2671,7 +2671,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Store the Nostr pubkey if provided (for messages from unknown senders)
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
let senderPeerID = message.senderPeerID {
let senderPeerID = message.senderPeer?.id {
// Store mapping for read receipts
nostrKeyMapping[senderPeerID] = nostrPubkey
}
@@ -2812,7 +2812,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: nil,
senderPeer: nil,
mentions: nil
)
@@ -2866,7 +2866,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
switch sessionState {
case .established:
// Send the message directly without going through sendPrivateMessage to avoid local echo
messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
messageRouter.sendPrivate(screenshotMessage, to: Peer(str: peerID), recipientNickname: peerNickname, messageID: UUID().uuidString)
default:
// Don't send screenshot notification if no session exists
SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security)
@@ -2882,7 +2882,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: true,
recipientNickname: meshService.peerNickname(peerID: peerID),
senderPeerID: meshService.myPeerID
senderPeer: Peer(str: meshService.myPeerID)
)
var chats = privateChats
if chats[peerID] == nil {
@@ -2988,7 +2988,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return
}
// Use router to decide (mesh if reachable, else Nostr if available)
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
messageRouter.sendReadReceipt(receipt, to: Peer(str: actualPeerID))
}
@MainActor
@@ -3001,7 +3001,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let messages = privateChats[peerID] ?? []
for message in messages where message.senderPeerID == peerID && !message.isRelay {
for message in messages where message.senderPeer?.id == peerID && !message.isRelay {
if !sentReadReceipts.contains(message.id) {
SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))", category: .session)
let nostrTransport = NostrTransport(keychain: keychain)
@@ -3043,13 +3043,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
for message in messagesToAck {
// Only send read ACKs for messages from the peer (not our own)
// Check both the ephemeral peer ID and stable Noise key as sender
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
if (message.senderPeer?.id == peerID || message.senderPeer?.id == noiseKeyHex) && !message.isRelay {
// Skip if we already sent an ACK for this message
if !sentReadReceipts.contains(message.id) {
// Use stable Noise key hex if available; else fall back to peerID
let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? peerID)
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
messageRouter.sendReadReceipt(receipt, to: recipPeer)
messageRouter.sendReadReceipt(receipt, to: Peer(str: recipPeer))
sentReadReceipts.insert(message.id)
}
}
@@ -3290,7 +3290,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
// Determine if this message was sent by self (mesh, geo, or DM)
let isSelf: Bool = {
if let spid = message.senderPeerID {
if let spid = message.senderPeer?.id {
// In geohash channels, compare against our per-geohash nostr short ID
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
@@ -3323,9 +3323,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
senderStyle.foregroundColor = baseColor
// Bold the user's own nickname
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced)
// Make sender clickable: encode senderPeerID into a custom URL
if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
if let spid = message.senderPeer?.id, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
senderStyle.link = url
}
@@ -3358,8 +3358,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
// Reuse compiled regexes and detector
@@ -3455,8 +3455,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
@@ -3486,7 +3486,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return false
}()
var mentionStyle = AttributeContainer()
mentionStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
mentionStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
let mentionColor: Color = isMentionToMe ? .orange : baseColor
mentionStyle.foregroundColor = mentionColor
// Emit '@'
@@ -3530,8 +3530,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}()
var tagStyle = AttributeContainer()
tagStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
tagStyle.foregroundColor = baseColor
if isGeohash && !attachedToMention && standalone, let url = URL(string: "bitchat://geohash/\(token)") {
tagStyle.link = url
@@ -3544,21 +3544,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
var spacer = AttributeContainer()
spacer.foregroundColor = baseColor
spacer.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
result.append(AttributedString(" ").mergingAttributes(spacer))
} else if type == "lightning" || type == "bolt11" || type == "lnurl" {
// Skip inline invoice/link; a styled chip is rendered below the message
var spacer = AttributeContainer()
spacer.foregroundColor = baseColor
spacer.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
result.append(AttributedString(" ").mergingAttributes(spacer))
} else {
// Keep URL styling and make it tappable via .link attribute
var matchStyle = AttributeContainer()
matchStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
matchStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
if type == "url" {
matchStyle.foregroundColor = isSelf ? .orange : .blue
matchStyle.underlineStyle = .single
@@ -3582,8 +3582,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
var remainingStyle = AttributeContainer()
remainingStyle.foregroundColor = baseColor
remainingStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .system(size: 14, weight: .bold, design: .monospaced)
: .system(size: 14, design: .monospaced)
if isMentioned {
remainingStyle.font = remainingStyle.font?.bold()
}
@@ -3595,21 +3595,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
timestampStyle.font = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
// System message
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
let content = AttributedString("* \(message.content) *")
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
contentStyle.font = .system(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
// Add timestamp at the end for system messages too
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
timestampStyle.font = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
@@ -3629,14 +3629,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let content = AttributedString("* \(message.content) *")
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
contentStyle.font = .system(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
// Add timestamp at the end for system messages
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
timestampStyle.font = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
let sender = AttributedString("<@\(message.sender)> ")
@@ -3646,7 +3646,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
senderStyle.foregroundColor = primaryColor
// Bold the user's own nickname
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 12, weight: fontWeight, design: .monospaced)
senderStyle.font = .system(size: 12, weight: fontWeight, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle))
@@ -3670,7 +3670,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
@@ -3679,7 +3679,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Add the mention with highlight
let mentionText = String(contentText[range])
var mentionStyle = AttributeContainer()
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
mentionStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
@@ -3691,7 +3691,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if lastEndIndex < contentText.endIndex {
let remainingText = String(contentText[lastEndIndex...])
var normalStyle = AttributeContainer()
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
}
@@ -3702,7 +3702,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let relay = AttributedString(" (via \(originalSender))")
var relayStyle = AttributeContainer()
relayStyle.foregroundColor = primaryColor.opacity(0.7)
relayStyle.font = .bitchatSystem(size: 11, design: .monospaced)
relayStyle.font = .system(size: 11, design: .monospaced)
result.append(relay.mergingAttributes(relayStyle))
}
@@ -3710,7 +3710,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
timestampStyle.font = .system(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
@@ -3730,7 +3730,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
func updateEncryptionStatusForPeer(_ peerID: String) {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
if noiseService.hasEstablishedSession(with: Peer(str: peerID)) {
// Check if fingerprint is verified using our persisted data
if let fingerprint = getFingerprint(for: peerID),
verifiedFingerprints.contains(fingerprint) {
@@ -3738,7 +3738,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} else {
peerEncryptionStatus[peerID] = .noiseSecured
}
} else if noiseService.hasSession(with: peerID) {
} else if noiseService.hasSession(with: Peer(str: peerID)) {
// Session exists but not established - handshaking
peerEncryptionStatus[peerID] = .noiseHandshaking
} else {
@@ -3855,7 +3855,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID {
if let spid = message.senderPeer?.id {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = {
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
@@ -4196,7 +4196,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func updateEncryptionStatus(for peerID: String) {
let noiseService = meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
if noiseService.hasEstablishedSession(with: Peer(str: peerID)) {
if let fingerprint = getFingerprint(for: peerID) {
if verifiedFingerprints.contains(fingerprint) {
peerEncryptionStatus[peerID] = .noiseVerified
@@ -4207,7 +4207,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Session established but no fingerprint yet
peerEncryptionStatus[peerID] = .noiseSecured
}
} else if noiseService.hasSession(with: peerID) {
} else if noiseService.hasSession(with: Peer(str: peerID)) {
peerEncryptionStatus[peerID] = .noiseHandshaking
} else {
peerEncryptionStatus[peerID] = Optional.none
@@ -4358,7 +4358,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Cache shortID -> full Noise key mapping as soon as session authenticates
if self.shortIDToNoiseKey[peerID] == nil,
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(Peer(str: peerID)) {
let stable = keyData.hexEncodedString()
self.shortIDToNoiseKey[peerID] = stable
SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))", category: .session)
@@ -4451,7 +4451,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: peerID,
senderPeer: Peer(str: peerID),
mentions: pmMentions.isEmpty ? nil : pmMentions
)
handlePrivateMessage(msg)
@@ -4558,7 +4558,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
senderPeer: Peer(str: peerID),
mentions: publicMentions.isEmpty ? nil : publicMentions
)
handlePublicMessage(msg)
@@ -4587,7 +4587,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
pendingQRVerifications[peerID] = pending
// If Noise session is established, send immediately; otherwise trigger handshake and send on auth
let noise = meshService.getNoiseService()
if noise.hasEstablishedSession(with: peerID) {
if noise.hasEstablishedSession(with: Peer(str: peerID)) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
pending.sent = true
pendingQRVerifications[peerID] = pending
@@ -4608,7 +4608,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isConnected = true
// Register ephemeral session with identity manager
identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
identityManager.registerEphemeralSession(peer: Peer(str: peerID), handshakeState: .none)
// Intentionally do not resend favorites on reconnect.
// We only send our npub when a favorite is toggled on, or if our npub changes.
@@ -4623,7 +4623,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Flush any queued messages for this peer via router
messageRouter.flushOutbox(for: peerID)
messageRouter.flushOutbox(for: Peer(str: peerID))
}
//
@@ -4633,12 +4633,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
// Remove ephemeral session from identity manager
identityManager.removeEphemeralSession(peerID: peerID)
identityManager.removeEphemeralSession(peer: Peer(str: peerID))
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
if derivedStableKeyHex == nil,
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let key = meshService.getNoiseService().getPeerPublicKeyData(Peer(str: peerID)) {
derivedStableKeyHex = key.hexEncodedString()
shortIDToNoiseKey[peerID] = derivedStableKeyHex
}
@@ -4659,7 +4659,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: msg.originalSender,
isPrivate: msg.isPrivate,
recipientNickname: msg.recipientNickname,
senderPeerID: (msg.senderPeerID == meshService.myPeerID) ? meshService.myPeerID : stableKeyHex,
senderPeer: (msg.senderPeer?.id == meshService.myPeerID) ? Peer(str: meshService.myPeerID) : Peer(str: stableKeyHex),
mentions: msg.mentions,
deliveryStatus: msg.deliveryStatus
)
@@ -4687,7 +4687,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let messages = privateChats[peerID] {
for message in messages {
// Remove read receipts for messages FROM this peer (not TO this peer)
if message.senderPeerID == peerID {
if message.senderPeer?.id == peerID {
sentReadReceipts.remove(message.id)
}
}
@@ -4740,7 +4740,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Register ephemeral sessions for all connected peers
for peerID in peers {
self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
self.identityManager.registerEphemeralSession(peer: Peer(str: peerID), handshakeState: .none)
}
// Schedule UI refresh to ensure offline favorites are shown
@@ -5224,7 +5224,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: targetPeerID,
senderPeer: Peer(str: targetPeerID),
deliveryStatus: .delivered(to: nickname, at: Date())
)
@@ -5312,7 +5312,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let key {
SecureLogger.debug("Sending DELIVERED ack for \(message.id.prefix(8))… via router", category: .session)
messageRouter.sendDeliveryAck(message.id, to: key.hexEncodedString())
messageRouter.sendDeliveryAck(message.id, to: Peer(str: key.hexEncodedString()))
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet send directly to sender's Nostr pubkey
let nt = NostrTransport(keychain: keychain)
@@ -5333,7 +5333,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let key {
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
messageRouter.sendReadReceipt(receipt, to: Peer(str: key.hexEncodedString()))
sentReadReceipts.insert(message.id)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain)
@@ -5366,7 +5366,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
peer: Peer(str: targetPeerID)
)
}
}
@@ -5548,7 +5548,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if senderNickname == nil {
for (_, messages) in privateChats {
if let previousMessage = messages.first(where: {
$0.senderPeerID == tempPeerID
$0.senderPeer?.id == tempPeerID
}) {
finalSenderNickname = previousMessage.sender
break
@@ -5566,7 +5566,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: tempPeerID,
senderPeer: Peer(str: tempPeerID),
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
@@ -5598,7 +5598,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: finalSenderNickname,
message: content,
peerID: tempPeerID
peer: Peer(str: tempPeerID)
)
} else {
// Not notifying for old message
@@ -5655,7 +5655,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
let peerIDHex = noisePublicKey.hexEncodedString()
messageRouter.sendFavoriteNotification(to: peerIDHex, isFavorite: isFavorite)
messageRouter.sendFavoriteNotification(to: Peer(str: peerIDHex), isFavorite: isFavorite)
}
@MainActor
@@ -5675,12 +5675,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Try mesh first for connected peers
if meshService.isPeerConnected(peerID) {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
messageRouter.sendFavoriteNotification(to: Peer(str: peerID), isFavorite: isFavorite)
SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
} else if let key = noiseKey {
// Send via Nostr for offline peers (using router)
let recipientPeerID = key.hexEncodedString()
messageRouter.sendFavoriteNotification(to: recipientPeerID, isFavorite: isFavorite)
messageRouter.sendFavoriteNotification(to: Peer(str: recipientPeerID), isFavorite: isFavorite)
} else {
SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
}
@@ -5691,7 +5691,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
/// Check if a message should be blocked based on sender
@MainActor
private func isMessageBlocked(_ message: BitchatMessage) -> Bool {
if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) {
if let peerID = message.senderPeer?.id ?? getPeerIDForNickname(message.sender) {
// Check mesh/known peers first
if isPeerBlocked(peerID) { return true }
// Check geohash (Nostr) blocks using mapping to full pubkey
@@ -5731,7 +5731,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
senderPeer: message.senderPeer,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
@@ -5840,7 +5840,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
let senderPeerID = message.senderPeer?.id ?? getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else {
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
@@ -5932,7 +5932,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: peerID
peer: Peer(str: peerID)
)
}
} else {
@@ -5951,7 +5951,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
readerNickname: nickname
)
let recipientID = message.senderPeerID ?? peerID
let recipientID = message.senderPeer?.id ?? peerID
Task { @MainActor in
var originalTransport: String? = nil
@@ -5983,7 +5983,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if isMessageBlocked(finalMessage) { return }
// Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system)
let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true
let isGeo = finalMessage.senderPeer?.isNostrColon == true
// Apply per-sender and per-content rate limits (drop if exceeded)
if finalMessage.sender != "system" {
+22 -32
View File
@@ -88,7 +88,7 @@ struct AppInfoView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
@@ -106,29 +106,16 @@ struct AppInfoView: View {
// Header
VStack(alignment: .center, spacing: 8) {
Text(Strings.appName)
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
.font(.system(size: 32, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Text(Strings.tagline)
.font(.bitchatSystem(size: 16, design: .monospaced))
.font(.system(size: 16, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
VStack(alignment: .leading, spacing: 8) {
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
Text(instruction)
}
}
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
// Features
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title)
@@ -175,13 +162,26 @@ struct AppInfoView: View {
description: Strings.Privacy.panic.2)
}
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
VStack(alignment: .leading, spacing: 8) {
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
Text(instruction)
}
}
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
}
// Warning
VStack(alignment: .leading, spacing: 6) {
SectionHeader(Strings.Warning.title)
.foregroundColor(Color.red)
Text(Strings.Warning.message)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(Color.red)
.fixedSize(horizontal: false, vertical: true)
}
@@ -211,7 +211,7 @@ struct SectionHeader: View {
var body: some View {
Text(title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
.padding(.top, 8)
}
@@ -234,17 +234,17 @@ struct FeatureRow: View {
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.bitchatSystem(size: 20))
.font(.system(size: 20))
.foregroundColor(textColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
Text(description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
@@ -254,16 +254,6 @@ struct FeatureRow: View {
}
}
#Preview("Default") {
#Preview {
AppInfoView()
}
#Preview("Dynamic Type XXL") {
AppInfoView()
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
}
#Preview("Dynamic Type XS") {
AppInfoView()
.environment(\.sizeCategory, .extraSmall)
}
+51 -57
View File
@@ -30,7 +30,6 @@ struct ContentView: View {
@State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var showPeerList = false
@State private var showSidebar = false
@State private var sidebarDragOffset: CGFloat = 0
@@ -54,7 +53,6 @@ struct ContentView: View {
@State private var showLocationNotes = false
@State private var notesGeohash: String? = nil
@State private var sheetNotesCount: Int = 0
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
// Timer-based refresh removed; use LocationChannelManager live updates instead
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@@ -65,18 +63,14 @@ struct ContentView: View {
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
private var headerLineLimit: Int? {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
// MARK: - Body
@@ -334,7 +328,7 @@ struct ContentView: View {
if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) }
}
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color.blue)
.padding(.top, 4)
}
@@ -465,7 +459,7 @@ struct ContentView: View {
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeer?.id == peerID && $0.sender != "system" })?.sender
}
}
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
@@ -712,7 +706,7 @@ struct ContentView: View {
}) {
HStack {
Text(suggestion)
.font(.bitchatSystem(size: 11, design: .monospaced))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
Spacer()
@@ -770,14 +764,14 @@ struct ContentView: View {
HStack {
// Show all aliases together
Text(info.commands.joined(separator: ", "))
.font(.bitchatSystem(size: 11, design: .monospaced))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor)
.fontWeight(.medium)
// Show syntax if any
if let syntax = info.syntax {
Text(syntax)
.font(.bitchatSystem(size: 10, design: .monospaced))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
@@ -785,7 +779,7 @@ struct ContentView: View {
// Show description
Text(info.description)
.font(.bitchatSystem(size: 10, design: .monospaced))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
.padding(.horizontal, 12)
@@ -808,7 +802,7 @@ struct ContentView: View {
HStack(alignment: .center, spacing: 4) {
TextField("type a message...", text: $messageText)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
.focused($isTextFieldFocused)
.padding(.leading, 12)
@@ -883,7 +877,7 @@ struct ContentView: View {
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.font(.system(size: 20))
.foregroundColor(messageText.isEmpty ? Color.gray :
viewModel.selectedPrivateChatPeer != nil
? Color.orange : textColor)
@@ -924,20 +918,20 @@ struct ContentView: View {
// Header - match main toolbar height
HStack {
Text("PEOPLE")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
// Show QR in mesh on all platforms
if case .mesh = locationManager.selectedChannel {
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
}
.buttonStyle(.plain)
.help("Verification: show my QR or scan a friend")
}
}
.frame(height: headerHeight) // Match header height
.frame(height: 44) // Match header height
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
@@ -1088,7 +1082,7 @@ struct ContentView: View {
private var mainHeaderView: some View {
HStack(spacing: 0) {
Text("bitchat/")
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
.font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.onTapGesture(count: 3) {
// PANIC: Triple-tap to clear all data
@@ -1101,12 +1095,12 @@ struct ContentView: View {
HStack(spacing: 0) {
Text("@")
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
TextField("nickname", text: $viewModel.nickname)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.frame(maxWidth: 80)
.foregroundColor(textColor)
.focused($isNicknameFieldFocused)
@@ -1145,7 +1139,7 @@ struct ContentView: View {
if viewModel.hasAnyUnreadMessages {
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 12))
.font(.system(size: 12))
.foregroundColor(Color.orange)
}
.buttonStyle(.plain)
@@ -1165,7 +1159,7 @@ struct ContentView: View {
let currentCount = (notesCounter.count ?? 0)
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
Image(systemName: "long.text.page.and.pencil")
.font(.bitchatSystem(size: 12))
.font(.system(size: 12))
.foregroundColor(hasNotes ? textColor : Color.gray)
.padding(.top, 1)
}
@@ -1179,7 +1173,7 @@ struct ContentView: View {
if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 12))
.font(.system(size: 12))
}
.buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
@@ -1202,9 +1196,9 @@ struct ContentView: View {
}
}()
Text(badgeText)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(badgeColor)
.lineLimit(headerLineLimit)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
.accessibilityLabel("location channels")
@@ -1216,15 +1210,15 @@ struct ContentView: View {
HStack(spacing: 4) {
// People icon with count
Image(systemName: "person.2.fill")
.font(.bitchatSystem(size: 11))
.font(.system(size: 11))
.accessibilityLabel("\(headerOtherPeersCount) people")
Text("\(headerOtherPeersCount)")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
.padding(.leading, 2)
.lineLimit(headerLineLimit)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
// QR moved to the PEOPLE header in the sidebar when on mesh channel
@@ -1241,7 +1235,7 @@ struct ContentView: View {
.environmentObject(viewModel)
}
}
.frame(height: headerHeight)
.frame(height: 44)
.padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
@@ -1257,22 +1251,22 @@ struct ContentView: View {
VStack(spacing: 12) {
HStack {
Text("notes")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.font(.system(size: 16, weight: .bold, design: .monospaced))
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: headerHeight)
.frame(height: 44)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Text("location unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
Button("enable location") {
LocationChannelManager.shared.enableLocationChannels()
@@ -1378,7 +1372,7 @@ struct ContentView: View {
!fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping
if headerPeerID.count == 16 {
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(Peer(str: headerPeerID))
if let id = candidates.first,
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
@@ -1423,19 +1417,19 @@ struct ContentView: View {
case .bluetoothConnected:
// Radio icon for mesh connection
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Connected via mesh")
case .meshReachable:
// point.3 filled icon for reachable via mesh (not directly connected)
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh")
case .nostrAvailable:
// Purple globe for Nostr
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
.foregroundColor(.purple)
.accessibilityLabel("Available via Nostr")
case .offline:
@@ -1445,25 +1439,25 @@ struct ContentView: View {
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
// Fallback: reachable via mesh but not in current peer list
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Reachable via mesh")
} else if isNostrAvailable {
// Fallback to Nostr if peer not in list but is mutual favorite
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
.foregroundColor(.purple)
.accessibilityLabel("Available via Nostr")
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
// Fallback: if peer lookup is missing but mesh reports connected, show radio
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
.foregroundColor(textColor)
.accessibilityLabel("Connected via mesh")
}
Text("\(privatePeerNick)")
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.font(.system(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID)
@@ -1476,7 +1470,7 @@ struct ContentView: View {
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
encryptionStatus == .noiseSecured ? textColor :
Color.red)
@@ -1498,7 +1492,7 @@ struct ContentView: View {
}
}) {
Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12))
.font(.system(size: 12))
.foregroundColor(textColor)
.frame(width: 44, height: 44, alignment: .leading)
.contentShape(Rectangle())
@@ -1514,7 +1508,7 @@ struct ContentView: View {
viewModel.toggleFavorite(peerID: headerPeerID)
}) {
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
.font(.bitchatSystem(size: 16))
.font(.system(size: 16))
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
@@ -1523,7 +1517,7 @@ struct ContentView: View {
}
}
}
.frame(height: headerHeight)
.frame(height: 44)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
}
@@ -1580,7 +1574,7 @@ private struct PaymentChipView: View {
HStack(spacing: 6) {
Text(emoji)
Text(label)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.font(.system(size: 12, weight: .semibold, design: .monospaced))
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -1621,20 +1615,20 @@ struct DeliveryStatusView: View {
switch status {
case .sending:
Image(systemName: "circle")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .sent:
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .delivered(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
}
.foregroundColor(textColor.opacity(0.8))
.help("Delivered to \(nickname)")
@@ -1642,25 +1636,25 @@ struct DeliveryStatusView: View {
case .read(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
.font(.system(size: 10, weight: .bold))
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
.font(.system(size: 10, weight: .bold))
}
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
.help("Read by \(nickname)")
case .failed(let reason):
Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(Color.red.opacity(0.8))
.help("Failed: \(reason)")
case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
Text("\(reached)/\(total)")
.font(.bitchatSystem(size: 10, design: .monospaced))
.font(.system(size: 10, design: .monospaced))
}
.foregroundColor(secondaryTextColor.opacity(0.6))
.help("Delivered to \(reached) of \(total) members")
+14 -14
View File
@@ -27,14 +27,14 @@ struct FingerprintView: View {
// Header
HStack {
Text("SECURITY VERIFICATION")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
.font(.system(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
}
@@ -66,17 +66,17 @@ struct FingerprintView: View {
HStack {
if let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 20))
.font(.system(size: 20))
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
}
VStack(alignment: .leading, spacing: 4) {
Text(peerNickname)
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
.font(.system(size: 18, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
Text(encryptionStatus.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(textColor.opacity(0.7))
}
@@ -89,12 +89,12 @@ struct FingerprintView: View {
// Their fingerprint
VStack(alignment: .leading, spacing: 8) {
Text("THEIR FINGERPRINT:")
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7))
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(nil)
@@ -115,7 +115,7 @@ struct FingerprintView: View {
}
} else {
Text("not available - handshake in progress")
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(Color.orange)
.padding()
}
@@ -124,12 +124,12 @@ struct FingerprintView: View {
// My fingerprint
VStack(alignment: .leading, spacing: 8) {
Text("YOUR FINGERPRINT:")
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7))
let myFingerprint = viewModel.getMyFingerprint()
Text(formatFingerprint(myFingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(nil)
@@ -156,14 +156,14 @@ struct FingerprintView: View {
VStack(spacing: 12) {
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(isVerified ? Color.green : Color.orange)
.frame(maxWidth: .infinity)
Text(isVerified ?
"you have verified this person's identity." :
"compare these fingerprints with \(peerNickname) using a secure channel.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.lineLimit(nil)
@@ -176,7 +176,7 @@ struct FingerprintView: View {
dismiss()
}) {
Text("MARK AS VERIFIED")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
@@ -190,7 +190,7 @@ struct FingerprintView: View {
dismiss()
}) {
Text("REMOVE VERIFICATION")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
+6 -6
View File
@@ -12,7 +12,7 @@ struct GeohashPeopleList: View {
if viewModel.visibleGeohashPeople().isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text("nobody around...")
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
@@ -51,30 +51,30 @@ struct GeohashPeopleList: View {
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
let (base, suffix) = splitSuffix(from: person.displayName)
HStack(spacing: 0) {
Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMe ? .bold : .regular)
.foregroundColor(rowColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(suffixColor)
}
if isMe {
Text(" (you)")
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(rowColor)
}
}
if let me = myHex, person.id != me {
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
Image(systemName: "nosign")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(.red)
.help("Blocked in geochash")
}
+46 -46
View File
@@ -20,14 +20,10 @@ struct LocationChannelsSheet: View {
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Text("#location channels")
.font(.bitchatSystem(size: 18, design: .monospaced))
Spacer()
closeButton
}
Text("#location channels")
.font(.system(size: 18, design: .monospaced))
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Group {
@@ -35,7 +31,7 @@ struct LocationChannelsSheet: View {
case LocationChannelManager.PermissionState.notDetermined:
Button(action: { manager.enableLocationChannels() }) {
Text("get location and my geohashes")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(standardGreen)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
@@ -46,7 +42,7 @@ struct LocationChannelsSheet: View {
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
VStack(alignment: .leading, spacing: 8) {
Text("location permission denied. enable in settings to use location channels.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Button("open settings") { openSystemLocationSettings() }
.buttonStyle(.plain)
@@ -64,9 +60,29 @@ struct LocationChannelsSheet: View {
.background(backgroundColor)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
}
#else
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .automatic) {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
}
#endif
}
#if os(iOS)
@@ -96,16 +112,6 @@ struct LocationChannelsSheet: View {
.onChange(of: manager.availableChannels) { _ in }
}
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
private var channelList: some View {
List {
// Mesh option first (no bookmark)
@@ -113,7 +119,6 @@ struct LocationChannelsSheet: View {
manager.select(ChannelID.mesh)
isPresented = false
}
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
// Nearby options
if !manager.availableChannels.isEmpty {
@@ -132,7 +137,7 @@ struct LocationChannelsSheet: View {
trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
@@ -143,22 +148,20 @@ struct LocationChannelsSheet: View {
manager.select(ChannelID.location(channel))
isPresented = false
}
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
}
} else {
HStack {
ProgressView()
Text("finding nearby channels…")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
}
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
}
// Custom geohash teleport
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) {
Text("#")
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
@@ -166,7 +169,7 @@ struct LocationChannelsSheet: View {
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
#endif
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.onChange(of: customGeohash) { newValue in
// Allow only geohash base32 characters, strip '#', limit length
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
@@ -194,13 +197,14 @@ struct LocationChannelsSheet: View {
}) {
HStack(spacing: 6) {
Text("teleport")
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
}
}
.buttonStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.secondary.opacity(0.12))
.cornerRadius(6)
@@ -209,17 +213,16 @@ struct LocationChannelsSheet: View {
}
if let err = customError {
Text(err)
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.red)
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
// Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("bookmarked")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
VStack(spacing: 6) {
ForEach(bookmarks.bookmarks, id: \.self) { gh in
@@ -236,7 +239,7 @@ struct LocationChannelsSheet: View {
trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 14))
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
@@ -260,18 +263,16 @@ struct LocationChannelsSheet: View {
.cornerRadius(8)
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
}
// Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
torToggleSection
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0))
Button(action: {
openSystemLocationSettings()
}) {
Text("remove location access")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
@@ -281,7 +282,6 @@ struct LocationChannelsSheet: View {
.buttonStyle(.plain)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0))
}
}
.listStyle(.plain)
@@ -319,12 +319,12 @@ struct LocationChannelsSheet: View {
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.font(.bitchatSystem(size: 11, design: .monospaced))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
@@ -335,7 +335,7 @@ struct LocationChannelsSheet: View {
return subtitlePrefix
}()
Text(subtitleFull)
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
@@ -343,7 +343,7 @@ struct LocationChannelsSheet: View {
Spacer()
if isSelected {
Text("✔︎")
.font(.bitchatSystem(size: 16, design: .monospaced))
.font(.system(size: 16, design: .monospaced))
.foregroundColor(standardGreen)
}
trailingAccessory()
@@ -425,10 +425,10 @@ extension LocationChannelsSheet {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text("tor routing")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(.primary)
Text("hides your ip for location channels. recommended: on.")
.font(.bitchatSystem(size: 11, design: .monospaced))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
@@ -459,7 +459,7 @@ private struct IRCToggleStyle: ToggleStyle {
Spacer()
Text(configuration.isOn ? "on" : "off")
.textCase(.uppercase)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
+113 -199
View File
@@ -7,7 +7,6 @@ struct LocationNotesView: View {
let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@ObservedObject private var locationManager = LocationChannelManager.shared
@Environment(\.dismiss) private var dismiss
@State private var draft: String = ""
@@ -19,24 +18,30 @@ struct LocationNotesView: View {
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
}
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
// Slightly darker green for hash suffix emphasis
private var darkerTextColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0)
}
var body: some View {
#if os(macOS)
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
headerSection
notesContent
}
}
.background(backgroundColor)
inputSection
header
Divider()
list
Divider()
input
}
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.background(backgroundColor)
.foregroundColor(textColor)
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
@@ -45,202 +50,100 @@ struct LocationNotesView: View {
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
#else
NavigationView {
VStack(spacing: 0) {
headerSection
ScrollView {
notesContent
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
let c = manager.notes.count
Text("\(c) \(c == 1 ? "note" : "notes") ")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("@ ")
.font(.system(size: 16, weight: .bold, design: .monospaced))
Text("#\(geohash)")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
inputSection
}
.background(backgroundColor)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
#else
.navigationTitle("")
#endif
}
#if os(iOS)
.presentationDetents([.large])
#endif
.background(backgroundColor)
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
}
.onAppear { onNotesCountChanged?(manager.notes.count) }
.onChange(of: manager.notes.count) { newValue in
onNotesCountChanged?(newValue)
}
#endif
}
private var closeButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
private var headerSection: some View {
let count = manager.notes.count
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text("#\(geohash)\(count) \(count == 1 ? "note" : "notes")")
.font(.bitchatSystem(size: 18, design: .monospaced))
Spacer()
closeButton
}
if let building = locationManager.locationNames[.building], !building.isEmpty {
Text(building)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
Text(block)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(accentGreen)
}
Text("add short permanent notes to this location for other visitors to find.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .loading && !manager.initialLoadComplete {
Text("loading recent notes…")
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
} else if manager.state == .noRelays {
Text("geo relays unavailable; notes paused")
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
}
private var notesContent: some View {
LazyVStack(alignment: .leading, spacing: 12) {
if manager.state == .noRelays {
noRelaysRow
} else if manager.state == .loading && !manager.initialLoadComplete {
loadingRow
} else if manager.notes.isEmpty {
emptyRow
} else {
ForEach(manager.notes) { note in
noteRow(note)
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
Text(buildingName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
} else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty {
Text(blockName)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(secondaryTextColor)
}
}
if let error = manager.errorMessage, manager.state != .noRelays {
errorRow(message: error)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
let ts = timestampText(for: note.createdAt)
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text("@\(baseName)")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
if !ts.isEmpty {
Text(ts)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
Spacer()
}
Text(note.content)
.font(.bitchatSystem(size: 14, design: .monospaced))
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
}
private var noRelaysRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("no geo relays nearby")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
Text("notes rely on geo relays. check connection and try again.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Button("retry") { manager.refresh() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.buttonStyle(.plain)
}
.padding(.vertical, 6)
}
private var loadingRow: some View {
HStack(spacing: 10) {
ProgressView()
Text("loading notes…")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Spacer()
}
.padding(.vertical, 8)
}
private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text("no notes yet")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
Text("be the first to add one for this spot.")
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
}
private func errorRow(message: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 12, design: .monospaced))
Text(message)
.font(.bitchatSystem(size: 12, design: .monospaced))
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.frame(width: 32, height: 32)
.contentShape(Rectangle())
}
Button("dismiss") { manager.clearError() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.buttonStyle(.plain)
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.padding(.vertical, 6)
.frame(height: 44)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
}
private var inputSection: some View {
HStack(alignment: .top, spacing: 10) {
private var list: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(manager.notes) { note in
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
// Show @name without the #abcd suffix; timestamp in brackets
HStack(spacing: 0) {
Text("@")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
let parts = splitSuffix(from: note.displayName)
Text(parts.0)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
}
let ts = timestampText(for: note.createdAt)
Text(ts.isEmpty ? "" : "[\(ts)]")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
}
Text(note.content)
.font(.system(size: 14, design: .monospaced))
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 12)
}
}
.padding(.vertical, 8)
}
.background(backgroundColor)
}
private var input: some View {
HStack(alignment: .center, spacing: 8) {
TextField("add a note for this place", text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
.font(.system(size: 14, design: .monospaced))
.lineLimit(3, reservesSpace: true)
.padding(.horizontal, 12)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill")
.font(.bitchatSystem(size: 20))
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
.font(.system(size: 20))
.foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor)
}
.padding(.top, 2)
.buttonStyle(.plain)
.disabled(!sendButtonEnabled)
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.padding(.trailing, 12)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(backgroundColor)
.overlay(Divider(), alignment: .top)
.frame(minHeight: 44)
.padding(.vertical, 8)
.background(backgroundColor.opacity(0.95))
}
private func send() {
@@ -250,17 +153,15 @@ struct LocationNotesView: View {
draft = ""
}
private var sendButtonEnabled: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
}
// MARK: - Timestamp Formatting
private func timestampText(for date: Date) -> String {
let now = Date()
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
// Relative (minute/hour/day), no seconds
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
return rel.isEmpty ? "" : "\(rel) ago"
} else {
// Absolute date (MMM d or MMM d, yyyy if different year)
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
return fmt.string(from: date)
@@ -288,3 +189,16 @@ struct LocationNotesView: View {
return f
}()
}
// Helper to split a trailing #abcd suffix
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
+14 -14
View File
@@ -15,7 +15,7 @@ struct MeshPeerList: View {
if viewModel.allPeers.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text("nobody around...")
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
@@ -45,27 +45,27 @@ struct MeshPeerList: View {
let baseColor = isMe ? Color.orange : assigned
if isMe {
Image(systemName: "person.fill")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isConnected {
// Mesh-connected peer: radio icon
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(.purple)
} else {
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
}
@@ -73,19 +73,19 @@ struct MeshPeerList: View {
let (base, suffix) = splitSuffix(from: displayName)
HStack(spacing: 0) {
Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(baseColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.font(.system(size: 14, design: .monospaced))
.foregroundColor(suffixColor)
}
}
if !isMe, viewModel.isPeerBlocked(peer.id) {
Image(systemName: "nosign")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(.red)
.help("Blocked")
}
@@ -94,7 +94,7 @@ struct MeshPeerList: View {
if peer.isConnected {
if let icon = item.enc.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(baseColor)
}
} else {
@@ -102,12 +102,12 @@ struct MeshPeerList: View {
if let fp = viewModel.getFingerprint(for: peer.id),
viewModel.verifiedFingerprints.contains(fp) {
Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if let icon = item.enc.icon {
// Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(baseColor)
}
}
@@ -118,7 +118,7 @@ struct MeshPeerList: View {
// Unread message indicator for this peer
if !isMe, item.hasUnread {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 10))
.font(.system(size: 10))
.foregroundColor(.orange)
.help("New messages")
}
@@ -126,7 +126,7 @@ struct MeshPeerList: View {
if !isMe {
Button(action: { onToggleFavorite(peer.id) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
.font(.bitchatSystem(size: 12))
.font(.system(size: 12))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
}
.buttonStyle(.plain)
+10 -10
View File
@@ -16,7 +16,7 @@ struct MyQRView: View {
var body: some View {
VStack(spacing: 12) {
Text("scan to verify me")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.font(.system(size: 16, weight: .bold, design: .monospaced))
VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240)
@@ -24,7 +24,7 @@ struct MyQRView: View {
// Non-scrolling, fully visible URL (wraps across lines)
Text(qrString)
.font(.bitchatSystem(size: 11, design: .monospaced))
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
@@ -60,7 +60,7 @@ struct QRCodeImage: View {
.frame(width: size, height: size)
.overlay(
Text("qr unavailable")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.gray)
)
}
@@ -119,7 +119,7 @@ struct QRScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
#else
Text("paste qr content to validate:")
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
.font(.system(size: 14, weight: .medium, design: .monospaced))
TextEditor(text: $input)
.frame(height: 100)
.border(Color.gray.opacity(0.4))
@@ -255,7 +255,7 @@ struct VerificationSheetView: View {
// Top header (always at top)
HStack {
Text("VERIFY")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(accentColor)
Spacer()
Button(action: {
@@ -263,7 +263,7 @@ struct VerificationSheetView: View {
isPresented = false
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
.font(.system(size: 14, weight: .semibold))
.foregroundColor(accentColor)
}
.buttonStyle(.plain)
@@ -279,7 +279,7 @@ struct VerificationSheetView: View {
if showingScanner {
VStack(alignment: .leading, spacing: 12) {
Text("scan a friend's qr")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.font(.system(size: 16, weight: .bold, design: .monospaced))
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
@@ -310,13 +310,13 @@ struct VerificationSheetView: View {
if showingScanner {
Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode")
.font(.bitchatSystem(size: 13, design: .monospaced))
.font(.system(size: 13, design: .monospaced))
}
.buttonStyle(.bordered)
} else {
Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))
.font(.system(size: 13, weight: .medium, design: .monospaced))
}
.buttonStyle(.bordered)
.tint(.gray)
@@ -328,7 +328,7 @@ struct VerificationSheetView: View {
viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
Label("remove verification", systemImage: "minus.circle")
.font(.bitchatSystem(size: 12, design: .monospaced))
.font(.system(size: 12, design: .monospaced))
}
.buttonStyle(.bordered)
.tint(.gray)
+3 -3
View File
@@ -134,7 +134,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "REMOTE123",
senderPeer: "REMOTE123",
mentions: nil
)
@@ -161,7 +161,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "PACKET123",
senderPeer: "PACKET123",
mentions: nil
)
@@ -243,7 +243,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "TEST123",
senderPeer: "TEST123",
mentions: nil
)
@@ -111,7 +111,7 @@ final class PublicChatE2ETests: XCTestCase {
originalSender: message.sender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
senderPeerID: message.senderPeer?.id,
mentions: message.mentions
)
@@ -439,7 +439,7 @@ final class PublicChatE2ETests: XCTestCase {
if let message = BitchatMessage(packet.payload) {
// Don't relay own messages
guard message.senderPeerID != node.peerID else { return }
guard message.senderPeer?.id != node.peerID else { return }
// Create relay message
let relayMessage = BitchatMessage(
@@ -451,7 +451,7 @@ final class PublicChatE2ETests: XCTestCase {
originalSender: message.isRelay ? message.originalSender : message.sender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
senderPeerID: message.senderPeer?.id,
mentions: message.mentions
)
-16
View File
@@ -1,16 +0,0 @@
import SwiftUI
import XCTest
@testable import bitchat
final class FontBitchatTests: XCTestCase {
func testMonospacedMapping() {
XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))
XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced))
XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
}
func testWeightIsPreserved() {
let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
}
}
-22
View File
@@ -1,22 +0,0 @@
import XCTest
@testable import bitchat
final class GCSFilterTests: XCTestCase {
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
let id = Data(repeating: 0xAB, count: 16)
let ids = Array(repeating: id, count: 64)
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
XCTAssertGreaterThanOrEqual(params.m, 1)
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
XCTAssertLessThanOrEqual(decoded.count, 1)
}
func testBucketAvoidsZeroCandidate() {
let id = Data(repeating: 0x01, count: 16)
let bucket = GCSFilter.bucket(for: id, modulus: 2)
XCTAssertNotEqual(bucket, 0)
XCTAssertLessThan(bucket, 2)
}
}
-70
View File
@@ -1,70 +0,0 @@
import Foundation
import XCTest
@testable import bitchat
final class GossipSyncManagerTests: XCTestCase {
func testConcurrentPacketIntakeAndSyncRequest() {
let manager = GossipSyncManager(myPeerID: "0102030405060708")
let delegate = RecordingDelegate()
let sendExpectation = expectation(description: "sync request sent")
delegate.onSend = { sendExpectation.fulfill() }
manager.delegate = delegate
let iterations = 200
let group = DispatchGroup()
for i in 0..<iterations {
group.enter()
DispatchQueue.global(qos: .userInitiated).async {
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: "1122334455667788") ?? Data(),
recipientID: nil,
timestamp: 1_000_000 + UInt64(i),
payload: Data([UInt8(truncatingIfNeeded: i)]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(packet)
Thread.sleep(forTimeInterval: 0.001)
group.leave()
}
}
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
}
group.wait()
wait(for: [sendExpectation], timeout: 2.0)
guard let lastPacket = delegate.lastPacket else {
XCTFail("Expected sync packet to be sent")
return
}
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
}
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)?
private(set) var lastPacket: BitchatPacket?
private let lock = NSLock()
func sendPacket(_ packet: BitchatPacket) {
lock.lock()
lastPacket = packet
lock.unlock()
onSend?()
}
func sendPacket(to peerID: String, packet: BitchatPacket) {
sendPacket(packet)
}
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
packet
}
}
@@ -627,7 +627,7 @@ final class IntegrationTests: XCTestCase {
guard packet.ttl > 1 else { return }
if let message = BitchatMessage(packet.payload) {
guard message.senderPeerID != node.peerID else { return }
guard message.senderPeer?.id != node.peerID else { return }
let relayMessage = BitchatMessage(
id: message.id,
@@ -638,7 +638,7 @@ final class IntegrationTests: XCTestCase {
originalSender: message.isRelay ? message.originalSender : message.sender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
senderPeerID: message.senderPeer?.id,
mentions: message.mentions
)
@@ -1,146 +0,0 @@
import XCTest
@testable import bitchat
@MainActor
final class LocationNotesManagerTests: XCTestCase {
func testSubscribeWithoutRelaysSetsNoRelaysState() {
var subscribeCalled = false
let deps = LocationNotesDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in
subscribeCalled = true
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in fatalError("should not derive identity") },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
XCTAssertFalse(subscribeCalled)
XCTAssertEqual(manager.state, .noRelays)
XCTAssertTrue(manager.initialLoadComplete)
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
}
func testSendWhenNoRelaysSurfacesError() {
var sendCalled = false
let deps = LocationNotesDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { _, _ in sendCalled = true },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
manager.send(content: "hello", nickname: "tester")
XCTAssertFalse(sendCalled)
XCTAssertEqual(manager.state, .noRelays)
XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.")
}
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
var relaysCaptured: [String] = []
var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)?
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(filter.kinds, [1])
XCTAssertFalse(id.isEmpty)
relaysCaptured = relays
storedHandler = handler
storedEOSE = eose
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
XCTAssertEqual(manager.state, .loading)
var event = NostrEvent(
pubkey: "pub",
createdAt: Date(),
kind: .textNote,
tags: [["g", "abcd1234"]],
content: "hi"
)
event.id = "event1"
storedHandler?(event)
storedEOSE?()
XCTAssertEqual(manager.state, .ready)
XCTAssertEqual(manager.notes.count, 1)
XCTAssertEqual(manager.notes.first?.content, "hi")
}
private enum TestError: Error {
case shouldNotDerive
}
}
@MainActor
final class LocationNotesCounterTests: XCTestCase {
func testSubscribeWithoutRelaysMarksUnavailable() {
var subscribeCalled = false
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in subscribeCalled = true },
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "abcdefgh")
XCTAssertFalse(subscribeCalled)
XCTAssertFalse(counter.relayAvailable)
XCTAssertTrue(counter.initialLoadComplete)
XCTAssertEqual(counter.count, 0)
}
func testSubscribeCountsUniqueNotes() {
var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)?
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in ["wss://relay.geo"] },
subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(relays, ["wss://relay.geo"])
XCTAssertEqual(filter.kinds, [1])
XCTAssertFalse(id.isEmpty)
storedHandler = handler
storedEOSE = eose
},
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "abcdefgh")
var first = NostrEvent(
pubkey: "pub",
createdAt: Date(),
kind: .textNote,
tags: [["g", "abcdefgh"]],
content: "a"
)
first.id = "eventA"
storedHandler?(first)
let duplicate = first
storedHandler?(duplicate)
storedEOSE?()
XCTAssertTrue(counter.relayAvailable)
XCTAssertEqual(counter.count, 1)
XCTAssertTrue(counter.initialLoadComplete)
}
}
+1 -1
View File
@@ -331,7 +331,7 @@ final class MockBLEService: NSObject {
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
for neighbor in neighbors() {
// Avoid immediate echo loopback to sender if known
if let sender = message.senderPeerID, sender == neighbor.peerID { continue }
if let sender = message.senderPeer?.id, sender == neighbor.peerID { continue }
var relay = packet
relay.ttl = nextTTL
neighbor.simulateIncomingPacket(relay)
@@ -1,111 +0,0 @@
import XCTest
@testable import bitchat
final class NotificationStreamAssemblerTests: XCTestCase {
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
return BitchatPacket(
type: MessageType.message.rawValue,
senderID: sender,
recipientID: nil,
timestamp: timestamp,
payload: Data([0xDE, 0xAD, 0xBE, 0xEF]),
signature: nil,
ttl: 3
)
}
func testAssemblesSingleFrameAcrossChunks() {
var assembler = NotificationStreamAssembler()
let packet = makePacket()
guard let frame = packet.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packet")
}
XCTAssertNotNil(BinaryProtocol.decode(frame))
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
XCTAssertEqual(payloadLen, packet.payload.count)
let splitIndex = min(20, max(1, frame.count / 2))
let first = frame.prefix(splitIndex)
let second = frame.suffix(from: splitIndex)
XCTAssertEqual(first.count + second.count, frame.count)
var result = assembler.append(first)
XCTAssertTrue(result.frames.isEmpty)
XCTAssertTrue(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset)
result = assembler.append(second)
XCTAssertEqual(result.frames.count, 1)
XCTAssertTrue(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset)
guard let frameData = result.frames.first else {
return XCTFail("Missing frame data")
}
if frameData.count != frame.count {
XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))")
return
}
guard let decoded = BinaryProtocol.decode(frameData) else {
return XCTFail("Failed to decode frame")
}
XCTAssertEqual(decoded.type, packet.type)
XCTAssertEqual(decoded.payload, packet.payload)
XCTAssertEqual(decoded.senderID, packet.senderID)
XCTAssertEqual(decoded.timestamp, packet.timestamp)
var directAssembler = NotificationStreamAssembler()
let directResult = directAssembler.append(frame)
XCTAssertEqual(directResult.frames.first?.count, frame.count)
}
func testAssemblesMultipleFramesSequentially() {
var assembler = NotificationStreamAssembler()
let packet1 = makePacket(timestamp: 0xABC)
let packet2 = makePacket(timestamp: 0xDEF)
guard let frame1 = packet1.toBinaryData(padding: false),
let frame2 = packet2.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packets")
}
var combined = Data()
combined.append(frame1)
combined.append(frame2)
let firstChunk = combined.prefix(20)
let secondChunk = combined.suffix(from: 20)
var result = assembler.append(firstChunk)
XCTAssertTrue(result.frames.isEmpty)
result = assembler.append(secondChunk)
XCTAssertEqual(result.frames.count, 2)
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
let decoded2 = BinaryProtocol.decode(result.frames[1]) else {
return XCTFail("Failed to decode frames")
}
XCTAssertEqual(decoded1.timestamp, packet1.timestamp)
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
}
func testDropsInvalidPrefixByte() {
var assembler = NotificationStreamAssembler()
let packet = makePacket(timestamp: 0xF00)
guard let frame = packet.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packet")
}
var noisyFrame = Data([0x00])
noisyFrame.append(frame)
let result = assembler.append(noisyFrame)
XCTAssertEqual(result.droppedPrefixes, [0x00])
XCTAssertEqual(result.frames.count, 1)
XCTAssertFalse(result.reset)
guard let decoded = BinaryProtocol.decode(result.frames[0]) else {
return XCTFail("Failed to decode frame after drop")
}
XCTAssertEqual(decoded.timestamp, packet.timestamp)
}
}
@@ -204,7 +204,7 @@ final class BinaryProtocolTests: XCTestCase {
XCTAssertEqual(decodedMessage.content, message.content)
XCTAssertEqual(decodedMessage.sender, message.sender)
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID)
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeer?.id)
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
// Timestamp should be close (within 1 second due to conversion)
+1 -1
View File
@@ -44,7 +44,7 @@ final class TestHelpers {
originalSender: nil,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
senderPeer: senderPeerID,
mentions: mentions
)
}
+247 -258
View File
@@ -1,275 +1,264 @@
Relay URL,Latitude,Longitude
nostr.tac.lol,47.4748,-122.273
relay.javi.space,43.4633,11.8796
nostr.einundzwanzig.space,50.1109,8.68213
nostr.kalf.org,52.3676,4.90414
wot.dtonon.com,43.6532,-79.3832
nostr-01.yakihonne.com,1.32123,103.695
wot.basspistol.org,49.4521,11.0767
relay.puresignal.news,43.6532,-79.3832
nostrelites.org,41.8781,-87.6298
nostr-relay.nextblockvending.com,47.674,-122.122
relay03.lnfi.network,39.0997,-94.5786
nproxy.kristapsk.lv,60.1699,24.9384
relay.toastr.net,40.8054,-74.0241
relay.davidebtc.me,51.5072,-0.127586
wot.nostr.net,43.6532,-79.3832
relay.mattybs.lol,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315
relay.endfiat.money,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384
nostr.kungfu-g.rip,33.7946,-84.4488
relay.mccormick.cx,52.3563,4.95714
relay.dwadziesciajeden.pl,52.2297,21.0122
nostr.data.haus,50.4754,12.3683
vitor.nostr1.com,40.7128,-74.006
purpura.cloud,43.6532,-79.3832
relay2.angor.io,48.1046,11.6002
nos.lol,50.4754,12.3683
nostr.rohoss.com,50.1109,8.68213
strfry.bonsai.com,37.8715,-122.273
relay.fountain.fm,39.0997,-94.5786
relay.npubhaus.com,43.6532,-79.3832
relay.nostr.wirednet.jp,34.706,135.493
soloco.nl,43.6532,-79.3832
shu01.shugur.net,21.4902,39.2246
nostr.davidebtc.me,51.5072,-0.127586
pyramid.fiatjaf.com,50.1109,8.68213
relay.ditto.pub,43.6532,-79.3832
relay.nostr.vet,52.6467,4.7395
relay.wavlake.com,41.2619,-95.8608
ribo.eu.nostria.app,52.3676,4.90414
relay.ngengine.org,43.6532,-79.3832
relay.bitcoinveneto.org,64.1466,-21.9426
no.str.cr,9.92857,-84.0528
relay.primal.net,43.6532,-79.3832
ynostr.yael.at,60.1699,24.9384
nostr.camalolo.com,24.1469,120.684
purplerelay.com,50.1109,8.68213
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
wheat.happytavern.co,43.6532,-79.3832
nostr.lostr.space,43.6532,-79.3832
relay.tagayasu.xyz,43.6715,-79.38
relay.varke.eu,52.6921,6.19372
free.relayted.de,50.1109,8.68213
nostr.thebiglake.org,32.71,-96.6745
nostr.lojong.info,43.6532,-79.3832
nostr.now,36.55,139.733
relay.jmoose.rocks,60.1699,24.9384
relay.holzeis.me,43.6532,-79.3832
nostr.roundrockbitcoiners.com,40.8054,-74.0241
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay2.ngengine.org,43.6532,-79.3832
nostr.snowbla.de,60.1699,24.9384
4u2ni0zjbjvni.clorecloud.net,43.6532,-79.3832
shu04.shugur.net,25.2604,55.2989
relay.fr13nd5.com,52.5233,13.3426
nostr.vulpem.com,49.4543,11.0746
temp.iris.to,43.6532,-79.3832
x.kojira.io,43.6532,-79.3832
adre.su,59.9311,30.3609
nostr-dev.wellorder.net,45.5201,-122.99
nostr.mom,50.4754,12.3683
relay.nostr.place,32.7767,-96.797
wot.nostr.place,30.2672,-97.7431
nostr.carroarmato0.be,50.9928,3.26317
nostrelay.circum.space,51.2217,6.77616
relay.chorus.community,50.1109,8.68213
relay.nostr.net,50.4754,12.3683
relay.nostr-check.me,43.6532,-79.3832
relay.nostrhub.fr,48.1046,11.6002
relay.nostraddress.com,43.6532,-79.3832
nostr.rblb.it,43.4633,11.8796
nostr.red5d.dev,43.6532,-79.3832
santo.iguanatech.net,40.8302,-74.1299
relay02.lnfi.network,39.0997,-94.5786
relay.21e6.cz,50.1682,14.0546
a.nos.lol,50.4754,12.3683
shu02.shugur.net,21.4902,39.2246
schnorr.me,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
wot.shaving.kiwi,43.6532,-79.3832
dev-nostr.bityacht.io,25.0797,121.234
relay.credenso.cafe,43.1149,-80.7228
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.mess.ch,47.3591,8.55292
inbox.azzamo.net,52.2633,21.0283
prl.plus,55.7623,37.6381
yabu.me,35.6092,139.73
relayrs.notoshi.win,43.6532,-79.3832
premium.primal.net,43.6532,-79.3832
nostr.coincrowd.fund,39.0438,-77.4874
nostr.2b9t.xyz,34.0549,-118.243
nostr.thaliyal.com,40.8218,-74.45
relay.exit.pub,50.4754,12.3683
nostr.jfischer.org,49.0291,8.35696
relay.origin.land,35.6673,139.751
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nostriot.com,41.5695,-83.9786
relay.btcforplebs.com,43.6532,-79.3832
relay.chakany.systems,43.6532,-79.3832
nostr.openhoofd.nl,51.9229,4.40833
nostrcheck.me,43.6532,-79.3832
nostr.plantroon.com,50.1013,8.62643
satsage.xyz,37.3986,-121.964
nostr.faultables.net,43.6532,-79.3832
nostr.calitabby.net,39.9268,-75.0246
relay.freeplace.nl,52.3676,4.90414
relay.nostrhub.tech,49.4543,11.0746
relay.bitcoinartclock.com,50.4754,12.3683
relay.nostromo.social,49.4543,11.0746
nostr.liberty.fans,36.9104,-89.5875
roles-az-achieving-somebody.trycloudflare.com,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683
cyberspace.nostr1.com,40.7128,-74.006
nostr.smut.cloud,43.6532,-79.3832
nostr-02.czas.top,53.471,9.88208
relay.tapestry.ninja,40.8054,-74.0241
relay.mostro.network,40.8302,-74.1299
wot.brightbolt.net,47.6735,-116.781
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.rikmeijer.nl,50.4754,12.3683
relay.artx.market,43.652,-79.3633
strfry.felixzieger.de,50.1013,8.62643
relay.seq1.net,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
relay.electriclifestyle.com,26.2897,-80.1293
r.bitcoinhold.net,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
relay.stream.labs.h3.se,59.4016,17.9455
relay.unknown.cloud,43.6532,-79.3832
nostr-02.yakihonne.com,1.32123,103.695
relay.coinos.io,43.6532,-79.3832
relay5.bitransfer.org,43.6532,-79.3832
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.nostr.wirednet.jp,34.706,135.493
nostr.einundzwanzig.space,50.1109,8.68213
relay.21e6.cz,50.1682,14.0546
relay04.lnfi.network,39.0997,-94.5786
relay.chorus.community,50.1109,8.68213
relay.nostr.place,32.7767,-96.797
relay.vrtmrz.net,43.6532,-79.3832
noxir.kpherox.dev,34.8587,135.509
wot.nostr.net,43.6532,-79.3832
relay.cypherflow.ai,48.8566,2.35222
wot.sudocarlos.com,51.5072,-0.127586
nostr.jerrynya.fun,31.2304,121.474
nostr2.girino.org,43.6532,-79.3832
nostrings-relay-dev.fly.dev,41.8781,-87.6298
fanfares.nostr1.com,40.7128,-74.006
nostr.red5d.dev,43.6532,-79.3832
nostr.hifish.org,47.4043,8.57398
nostr.now,36.55,139.733
relay.nostr.band,60.1699,24.9384
relay.wavlake.com,41.2619,-95.8608
nostr.bilthon.dev,25.8128,-80.2377
khatru.nostrver.se,51.8933,4.42083
relay.bitcoindistrict.org,43.6532,-79.3832
nostr.makibisskey.work,43.6532,-79.3832
relay.nostraddress.com,43.6532,-79.3832
relay.jmoose.rocks,60.1699,24.9384
relay.davidebtc.me,51.5072,-0.127586
a.nos.lol,50.4754,12.3683
nostr.tadryanom.me,43.6532,-79.3832
relay.nostrdice.com,-33.8688,151.209
relay.lumina.rocks,49.0291,8.35695
relay.goodmorningbitcoin.com,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.mattybs.lol,43.6532,-79.3832
relay-dev.satlantis.io,40.8302,-74.1299
nostream.breadslice.com,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589
nostr.oxtr.dev,50.4754,12.3683
nos.xmark.cc,50.6924,3.20113
nostr.mikoshi.de,50.1109,8.68213
relay.magiccity.live,25.8128,-80.2377
nostr-verified.wellorder.net,45.5201,-122.99
nostr.makibisskey.work,43.6532,-79.3832
wot.nostr.party,36.1627,-86.7816
relay.copylaradio.com,51.223,6.78245
nostr.sathoarder.com,48.5734,7.75211
relay.jeffg.fyi,43.6532,-79.3832
relay.wellorder.net,45.5201,-122.99
nostr.ovia.to,43.6532,-79.3832
black.nostrcity.club,41.8781,-87.6298
relay.nostrcheck.me,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nostr-03.dorafactory.org,1.35208,103.82
nostr-relay.zimage.com,34.282,-118.439
relay.sigit.io,50.4754,12.3683
relay.laantungir.net,-19.4692,-42.5315
relay-admin.thaliyal.com,40.8218,-74.45
relay.mwaters.net,50.9871,2.12554
relay.lumina.rocks,49.0291,8.35695
nostr.satstralia.com,64.1476,-21.9392
nostr.sagaciousd.com,49.2827,-123.121
relay.bullishbounty.com,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392
relay.vrtmrz.net,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
nostr.vulpem.com,49.4543,11.0746
nostr.rohoss.com,50.1109,8.68213
articles.layer3.news,37.3387,-121.885
relay.digitalezukunft.cyou,45.5019,-73.5674
nostr.notribe.net,40.8302,-74.1299
nostr.21crypto.ch,47.4988,8.72369
relay.lifpay.me,1.35208,103.82
relay.g1sms.fr,43.9432,2.07537
relay.snort.social,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
relay.moinsen.com,50.4754,12.3683
nostr.blankfors.se,60.1699,24.9384
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
orangesync.tech,50.1109,8.68213
relay.ru.ac.th,13.7584,100.622
orangepiller.org,60.1699,24.9384
relay.nostr.band,60.1699,24.9384
wot.soundhsa.com,34.0479,-118.256
khatru.nostrver.se,51.8933,4.42083
nostr.hifish.org,47.4043,8.57398
freelay.sovbit.host,64.1476,-21.9392
nostr.bilthon.dev,25.8128,-80.2377
nostr.night7.space,50.4754,12.3683
relay.illuminodes.com,47.6061,-122.333
relayone.soundhsa.com,34.0479,-118.256
nostr.azzamo.net,52.2633,21.0283
fenrir-s.notoshi.win,43.6532,-79.3832
nostr.spicyz.io,43.6532,-79.3832
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr.coincards.com,53.5501,-113.469
nos.lol,50.4754,12.3683
relay.artx.market,43.652,-79.3633
wot.sebastix.social,51.8933,4.42083
alien.macneilmediagroup.com,43.6532,-79.3832
nostrings-relay-dev.fly.dev,41.8781,-87.6298
relay.satlantis.io,32.8769,-80.0114
srtrelay.c-stellar.net,43.6532,-79.3832
relay.agora.social,50.7383,15.0648
relay.bitcoindistrict.org,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
wot.utxo.one,43.6532,-79.3832
relay.bitcoinartclock.com,50.4754,12.3683
relay.unknown.cloud,43.6532,-79.3832
nostr.lojong.info,43.6532,-79.3832
nostr.zenon.network,43.5009,-70.4428
wot.codingarena.top,50.4754,12.3683
nostr-02.dorafactory.org,1.35208,103.82
relay.nostromo.social,49.4543,11.0746
theoutpost.life,64.1476,-21.9392
strfry.shock.network,41.8959,-88.2169
strfry.openhoofd.nl,51.9229,4.40833
itanostr.space,52.2931,4.79099
relay.barine.co,43.6532,-79.3832
nostr.middling.mydns.jp,35.8099,140.12
relay.olas.app,50.4754,12.3683
nostr.girino.org,43.6532,-79.3832
nostr-relay.online,43.6532,-79.3832
relay.evanverma.com,40.8302,-74.1299
relay.angor.io,48.1046,11.6002
relay.nostrdice.com,-33.8688,151.209
ribo.us.nostria.app,41.5868,-93.625
relay01.lnfi.network,39.0997,-94.5786
nostr.namek.link,43.6532,-79.3832
relay.aloftus.io,34.0881,-118.379
orangesync.tech,50.1109,8.68213
nostr.davidebtc.me,51.5072,-0.127586
internationalright-wing.org,-22.5022,-48.7114
nostr.rikmeijer.nl,50.4754,12.3683
ynostr.yael.at,60.1699,24.9384
ithurtswhenip.ee,51.223,6.78245
ribo.af.nostria.app,-26.2041,28.0473
shu05.shugur.net,48.8566,2.35222
noxir.kpherox.dev,34.8587,135.509
relay.degmods.com,50.4754,12.3683
relay.goodmorningbitcoin.com,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.hasenpfeffr.com,39.0438,-77.4874
fanfares.nostr1.com,40.7128,-74.006
relay.nosto.re,51.8933,4.42083
nostr.liberty.fans,36.9104,-89.5875
nostr-2.21crypto.ch,47.4988,8.72369
relay-rpi.edufeed.org,49.4543,11.0746
offchain.pub,36.1809,-115.241
relay.wellorder.net,45.5201,-122.99
nostr.sathoarder.com,48.5734,7.75211
purplerelay.com,50.1109,8.68213
yabu.me,35.6092,139.73
nostr.88mph.life,43.6532,-79.3832
nostr.luisschwab.net,43.6532,-79.3832
nostr.casa21.space,43.6532,-79.3832
nostr.hekster.org,37.3986,-121.964
relay.utxo.farm,35.6916,139.768
nostr.overmind.lol,43.6532,-79.3832
rnostr.breadslice.com,43.6532,-79.3832
zap.watch,45.5029,-73.5723
nostr-pub.wellorder.net,45.5201,-122.99
wot.sovbit.host,64.1466,-21.9426
relay.endfiat.money,43.6532,-79.3832
nostr2.girino.org,43.6532,-79.3832
nostr.jerrynya.fun,31.2304,121.474
nostr.spacecitynode.com,29.7057,-95.2706
wot.basspistol.org,49.4521,11.0767
shu01.shugur.net,21.4902,39.2246
relay.electriclifestyle.com,26.2897,-80.1293
relay.mccormick.cx,52.3563,4.95714
nostr.middling.mydns.jp,35.8099,140.12
nostr.smut.cloud,43.6532,-79.3832
satsage.xyz,37.3986,-121.964
srtrelay.c-stellar.net,43.6532,-79.3832
nostr.0x7e.xyz,47.4988,8.72369
shu02.shugur.net,21.4902,39.2246
nostrelites.org,41.8781,-87.6298
relay-admin.thaliyal.com,40.8218,-74.45
wot.soundhsa.com,34.0479,-118.256
nostrcheck.me,43.6532,-79.3832
relay.nostrhub.tech,49.4543,11.0746
relay.stream.labs.h3.se,59.4016,17.9455
nostrelay.memory-art.xyz,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
relay.nosto.re,51.8933,4.42083
nostr.girino.org,43.6532,-79.3832
relay.siamdev.cc,13.9178,100.424
relay.notoshi.win,13.7829,100.546
relay.nostx.io,43.6532,-79.3832
relay.cypherflow.ai,48.8566,2.35222
relay.letsfo.com,51.098,17.0321
librerelay.aaroniumii.com,43.6532,-79.3832
gnostr.com,40.9017,29.1616
nostr.pleb.one,38.6327,-90.1961
nostr.mehdibekhtaoui.com,49.4939,-1.54813
nostr.tadryanom.me,43.6532,-79.3832
relay.orangepill.ovh,49.1689,-0.358841
nostr.stakey.net,52.3676,4.90414
nostr.rtvslawenia.com,49.4543,11.0746
orangepiller.org,60.1699,24.9384
nostr.plantroon.com,50.1013,8.62643
nostr-verified.wellorder.net,45.5201,-122.99
relay.primal.net,43.6532,-79.3832
relay.bitcoinveneto.org,64.1466,-21.9426
relay.hasenpfeffr.com,39.0438,-77.4874
strfry.openhoofd.nl,51.9229,4.40833
relay.aloftus.io,34.0881,-118.379
nostr.spaceshell.xyz,43.6532,-79.3832
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
ribo.af.nostria.app,-26.2041,28.0473
nostr.tac.lol,47.4748,-122.273
relay.satlantis.io,32.8769,-80.0114
nostr.azzamo.net,52.2633,21.0283
strfry.bonsai.com,37.8715,-122.273
relay.agora.social,50.7383,15.0648
nostr-relay.amethyst.name,39.0067,-77.4291
relay.toastr.net,40.8054,-74.0241
nostr.thebiglake.org,32.71,-96.6745
nostr-relay.nextblockvending.com,47.674,-122.122
vitor.nostr1.com,40.7057,-74.0136
relay.btcforplebs.com,43.6532,-79.3832
relay.g1sms.fr,43.9432,2.07537
nostr.jfischer.org,49.0291,8.35696
nostr.mikoshi.de,52.52,13.405
relay.notoshi.win,13.7829,100.546
pyramid.fiatjaf.com,50.1109,8.68213
relay.coinos.io,43.6532,-79.3832
relay.freeplace.nl,52.3676,4.90414
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.copylaradio.com,51.223,6.78245
relay.exit.pub,50.4754,12.3683
freelay.sovbit.host,64.1476,-21.9392
nostr.satstralia.com,64.1476,-21.9392
nostr.l484.com,30.2944,-97.6223
nostr.rblb.it,43.4633,11.8796
nostr.2b9t.xyz,34.0549,-118.243
nostr.dlsouza.lol,50.1109,8.68213
strfry.shock.network,41.8959,-88.2169
offchain.pub,36.1809,-115.241
nostr-01.yakihonne.com,1.32123,103.695
nostr.kungfu-g.rip,33.7946,-84.4488
relay.letsfo.com,51.098,17.0321
relay.lifpay.me,1.35208,103.82
relay.damus.io,43.6532,-79.3832
dev-relay.lnfi.network,39.0997,-94.5786
slick.mjex.me,39.048,-77.4817
wot.sudocarlos.com,51.5072,-0.127586
relay04.lnfi.network,39.0997,-94.5786
relay2.angor.io,48.1046,11.6002
relayrs.notoshi.win,43.6532,-79.3832
relay2.ngengine.org,43.6532,-79.3832
portal-relay.pareto.space,49.4543,11.0746
inbox.azzamo.net,52.2633,21.0283
nostr-dev.wellorder.net,45.5201,-122.99
nostr.stakey.net,52.3676,4.90414
relay.13room.space,43.6532,-79.3832
relay.conduit.market,43.6532,-79.3832
relay.fountain.fm,39.0997,-94.5786
black.nostrcity.club,41.8781,-87.6298
nostr-2.21crypto.ch,47.4988,8.72369
dev-nostr.bityacht.io,25.0797,121.234
santo.iguanatech.net,40.8302,-74.1299
relay.angor.io,48.1046,11.6002
relay.tagayasu.xyz,43.6715,-79.38
relay.npubhaus.com,43.6532,-79.3832
relay01.lnfi.network,39.0997,-94.5786
nostr.myshosholoza.co.za,52.3676,4.90414
relay02.lnfi.network,39.0997,-94.5786
gnostr.com,40.9017,29.1616
nostr.sagaciousd.com,49.2827,-123.121
nostr.night7.space,50.4754,12.3683
schnorr.me,43.6532,-79.3832
nostr.blankfors.se,60.1699,24.9384
relay.mostro.network,40.8302,-74.1299
purpura.cloud,43.6532,-79.3832
ribo.eu.nostria.app,52.3676,4.90414
vidono.apps.slidestr.net,48.8566,2.35222
wheat.happytavern.co,43.6532,-79.3832
nostr.faultables.net,43.6532,-79.3832
relay5.bitransfer.org,43.6532,-79.3832
relay.nostrhub.fr,48.1046,11.6002
nostr.thaliyal.com,40.8218,-74.45
relay.holzeis.me,43.6532,-79.3832
relay.nostriot.com,41.5695,-83.9786
nostr.openhoofd.nl,51.9229,4.40833
relay.nostr.vet,52.6467,4.7395
nostr.camalolo.com,24.1469,120.684
relay.origin.land,35.6673,139.751
relay.chakany.systems,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
nostr.mom,50.4754,12.3683
4u2ni0zjbjvni.clorecloud.net,43.6532,-79.3832
prl.plus,55.7623,37.6381
relay.moinsen.com,50.4754,12.3683
nostr-02.czas.top,53.471,9.88208
relay.sigit.io,50.4754,12.3683
relay.nostrcheck.me,43.6532,-79.3832
relay03.lnfi.network,39.0997,-94.5786
relay.sincensura.org,43.6532,-79.3832
nostr.coincards.com,53.5501,-113.469
nostr-03.dorafactory.org,1.35208,103.82
relay.credenso.cafe,43.1149,-80.7228
nostr.fbxl.net,48.3809,-89.2477
relay.bullishbounty.com,43.6532,-79.3832
nos.xmark.cc,50.6924,3.20113
x.kojira.io,43.6532,-79.3832
wot.sovbit.host,64.1466,-21.9426
shu05.shugur.net,48.8566,2.35222
nostr.carroarmato0.be,50.9928,3.26317
relay.cosmicbolt.net,37.3986,-121.964
r.bitcoinhold.net,43.6532,-79.3832
nostr.diakod.com,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nostr.coincrowd.fund,39.0438,-77.4874
cyberspace.nostr1.com,40.7128,-74.006
relay.barine.co,43.6532,-79.3832
relay.orangepill.ovh,49.1689,-0.358841
no.str.cr,9.92857,-84.0528
nostr.casa21.space,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
relay.magiccity.live,25.8128,-80.2377
relayone.soundhsa.com,34.0479,-118.256
slick.mjex.me,39.048,-77.4817
relay.utxo.farm,35.6916,139.768
theoutpost.life,64.1476,-21.9392
nostr.hekster.org,37.3986,-121.964
strfry.felixzieger.de,50.1013,8.62643
relay.mess.ch,47.3591,8.55292
wot.codingarena.top,50.4754,12.3683
nostrelay.circum.space,51.2217,6.77616
nostr-relay.online,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392
wot.brightbolt.net,47.6735,-116.781
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
wot.nostr.place,30.2672,-97.7431
ribo.us.nostria.app,41.5868,-93.625
relay.nostr.net,50.4754,12.3683
nostr-02.dorafactory.org,1.35208,103.82
relay.tapestry.ninja,40.8054,-74.0241
adre.su,59.9311,30.3609
librerelay.aaroniumii.com,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
kitchen.zap.cooking,43.6532,-79.3832
nostr.21crypto.ch,47.4988,8.72369
nostr-02.yakihonne.com,1.32123,103.695
relay.javi.space,43.4633,11.8796
nostr.ser1.net,12.9716,77.5946
relay-rpi.edufeed.org,49.4543,11.0746
premium.primal.net,43.6532,-79.3832
relay.degmods.com,50.4754,12.3683
relay.arx-ccn.com,50.4754,12.3683
nostr.chaima.info,51.223,6.78245
relay.illuminodes.com,47.6061,-122.333
relay.nostx.io,43.6532,-79.3832
relay.puresignal.news,43.6532,-79.3832
fenrir-s.notoshi.win,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
relay.conduit.market,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
nproxy.kristapsk.lv,60.1699,24.9384
relay.olas.app,50.4754,12.3683
relay.dwadziesciajeden.pl,52.2297,21.0122
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.pleb.one,38.6327,-90.1961
relay.digitalezukunft.cyou,45.5019,-73.5674
relay.evanverma.com,40.8302,-74.1299
wot.dtonon.com,43.6532,-79.3832
relay.seq1.net,43.6532,-79.3832
nostr.kalf.org,52.3676,4.90414
nostr.snowbla.de,60.1699,24.9384
nostr.spicyz.io,43.6532,-79.3832
nostr-relay.zimage.com,34.282,-118.439
nostr.spacecitynode.com,29.7057,-95.2706
dev-relay.lnfi.network,39.0997,-94.5786
itanostr.space,52.2931,4.79099
1 Relay URL Latitude Longitude
2 nostr.tac.lol relay.laantungir.net 47.4748 -19.4692 -122.273 -42.5315
3 relay.javi.space relay.endfiat.money 43.4633 43.6532 11.8796 -79.3832
nostr.einundzwanzig.space 50.1109 8.68213
nostr.kalf.org 52.3676 4.90414
wot.dtonon.com 43.6532 -79.3832
nostr-01.yakihonne.com 1.32123 103.695
wot.basspistol.org 49.4521 11.0767
relay.puresignal.news 43.6532 -79.3832
nostrelites.org 41.8781 -87.6298
nostr-relay.nextblockvending.com 47.674 -122.122
relay03.lnfi.network 39.0997 -94.5786
nproxy.kristapsk.lv 60.1699 24.9384
relay.toastr.net 40.8054 -74.0241
relay.davidebtc.me 51.5072 -0.127586
wot.nostr.net 43.6532 -79.3832
relay.mattybs.lol 43.6532 -79.3832
4 relay.zone667.com 60.1699 24.9384
nostr.kungfu-g.rip 33.7946 -84.4488
relay.mccormick.cx 52.3563 4.95714
relay.dwadziesciajeden.pl 52.2297 21.0122
nostr.data.haus 50.4754 12.3683
vitor.nostr1.com 40.7128 -74.006
purpura.cloud 43.6532 -79.3832
relay2.angor.io 48.1046 11.6002
nos.lol 50.4754 12.3683
nostr.rohoss.com 50.1109 8.68213
strfry.bonsai.com 37.8715 -122.273
relay.fountain.fm 39.0997 -94.5786
relay.npubhaus.com 43.6532 -79.3832
relay.nostr.wirednet.jp 34.706 135.493
soloco.nl 43.6532 -79.3832
shu01.shugur.net 21.4902 39.2246
nostr.davidebtc.me 51.5072 -0.127586
pyramid.fiatjaf.com 50.1109 8.68213
relay.ditto.pub 43.6532 -79.3832
relay.nostr.vet 52.6467 4.7395
relay.wavlake.com 41.2619 -95.8608
ribo.eu.nostria.app 52.3676 4.90414
relay.ngengine.org 43.6532 -79.3832
relay.bitcoinveneto.org 64.1466 -21.9426
no.str.cr 9.92857 -84.0528
relay.primal.net 43.6532 -79.3832
ynostr.yael.at 60.1699 24.9384
nostr.camalolo.com 24.1469 120.684
purplerelay.com 50.1109 8.68213
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
relay.internationalright-wing.org -22.5022 -48.7114
wheat.happytavern.co 43.6532 -79.3832
nostr.lostr.space 43.6532 -79.3832
relay.tagayasu.xyz 43.6715 -79.38
relay.varke.eu 52.6921 6.19372
free.relayted.de 50.1109 8.68213
nostr.thebiglake.org 32.71 -96.6745
nostr.lojong.info 43.6532 -79.3832
nostr.now 36.55 139.733
relay.jmoose.rocks 60.1699 24.9384
relay.holzeis.me 43.6532 -79.3832
nostr.roundrockbitcoiners.com 40.8054 -74.0241
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay2.ngengine.org 43.6532 -79.3832
nostr.snowbla.de 60.1699 24.9384
4u2ni0zjbjvni.clorecloud.net 43.6532 -79.3832
5 shu04.shugur.net 25.2604 55.2989
6 relay.fr13nd5.com relay.bitcoinartclock.com 52.5233 50.4754 13.3426 12.3683
7 nostr.vulpem.com relay.nostromo.social 49.4543 11.0746
8 temp.iris.to nostr.liberty.fans 43.6532 36.9104 -79.3832 -89.5875
x.kojira.io 43.6532 -79.3832
adre.su 59.9311 30.3609
nostr-dev.wellorder.net 45.5201 -122.99
nostr.mom 50.4754 12.3683
relay.nostr.place 32.7767 -96.797
wot.nostr.place 30.2672 -97.7431
nostr.carroarmato0.be 50.9928 3.26317
nostrelay.circum.space 51.2217 6.77616
relay.chorus.community 50.1109 8.68213
relay.nostr.net 50.4754 12.3683
relay.nostr-check.me 43.6532 -79.3832
relay.nostrhub.fr 48.1046 11.6002
relay.nostraddress.com 43.6532 -79.3832
nostr.rblb.it 43.4633 11.8796
nostr.red5d.dev 43.6532 -79.3832
santo.iguanatech.net 40.8302 -74.1299
relay02.lnfi.network 39.0997 -94.5786
relay.21e6.cz 50.1682 14.0546
a.nos.lol 50.4754 12.3683
shu02.shugur.net 21.4902 39.2246
schnorr.me 43.6532 -79.3832
nostr.n7ekb.net 47.4941 -122.294
wot.shaving.kiwi 43.6532 -79.3832
dev-nostr.bityacht.io 25.0797 121.234
relay.credenso.cafe 43.1149 -80.7228
relay-testnet.k8s.layer3.news 37.3387 -121.885
relay.mess.ch 47.3591 8.55292
inbox.azzamo.net 52.2633 21.0283
prl.plus 55.7623 37.6381
yabu.me 35.6092 139.73
relayrs.notoshi.win 43.6532 -79.3832
premium.primal.net 43.6532 -79.3832
nostr.coincrowd.fund 39.0438 -77.4874
nostr.2b9t.xyz 34.0549 -118.243
nostr.thaliyal.com 40.8218 -74.45
relay.exit.pub 50.4754 12.3683
nostr.jfischer.org 49.0291 8.35696
relay.origin.land 35.6673 139.751
nostr.myshosholoza.co.za 52.3676 4.90414
relay.nostriot.com 41.5695 -83.9786
relay.btcforplebs.com 43.6532 -79.3832
relay.chakany.systems 43.6532 -79.3832
nostr.openhoofd.nl 51.9229 4.40833
nostrcheck.me 43.6532 -79.3832
nostr.plantroon.com 50.1013 8.62643
satsage.xyz 37.3986 -121.964
nostr.faultables.net 43.6532 -79.3832
nostr.calitabby.net 39.9268 -75.0246
relay.freeplace.nl 52.3676 4.90414
relay.nostrhub.tech 49.4543 11.0746
9 roles-az-achieving-somebody.trycloudflare.com 43.6532 -79.3832
10 relay.arx-ccn.com nostr-rs-relay.dev.fedibtc.com 50.4754 39.0438 12.3683 -77.4874
11 cyberspace.nostr1.com relay.nostr.wirednet.jp 40.7128 34.706 -74.006 135.493
12 nostr.smut.cloud nostr.einundzwanzig.space 43.6532 50.1109 -79.3832 8.68213
13 nostr-02.czas.top relay.21e6.cz 53.471 50.1682 9.88208 14.0546
14 relay.tapestry.ninja relay04.lnfi.network 40.8054 39.0997 -74.0241 -94.5786
15 relay.mostro.network relay.chorus.community 40.8302 50.1109 -74.1299 8.68213
16 wot.brightbolt.net relay.nostr.place 47.6735 32.7767 -116.781 -96.797
17 nostr.spaceshell.xyz relay.vrtmrz.net 43.6532 -79.3832
18 nostr.rikmeijer.nl noxir.kpherox.dev 50.4754 34.8587 12.3683 135.509
19 relay.artx.market wot.nostr.net 43.652 43.6532 -79.3633 -79.3832
20 strfry.felixzieger.de relay.cypherflow.ai 50.1013 48.8566 8.62643 2.35222
21 relay.seq1.net wot.sudocarlos.com 43.6532 51.5072 -79.3832 -0.127586
22 relay.cosmicbolt.net nostr.jerrynya.fun 37.3986 31.2304 -121.964 121.474
23 relay.electriclifestyle.com nostr2.girino.org 26.2897 43.6532 -80.1293 -79.3832
24 r.bitcoinhold.net nostrings-relay-dev.fly.dev 43.6532 41.8781 -79.3832 -87.6298
25 nostr-relay.amethyst.name fanfares.nostr1.com 39.0067 40.7128 -77.4291 -74.006
26 relay.stream.labs.h3.se nostr.red5d.dev 59.4016 43.6532 17.9455 -79.3832
27 relay.unknown.cloud nostr.hifish.org 43.6532 47.4043 -79.3832 8.57398
28 nostr-02.yakihonne.com nostr.now 1.32123 36.55 103.695 139.733
29 relay.coinos.io relay.nostr.band 43.6532 60.1699 -79.3832 24.9384
30 relay5.bitransfer.org relay.wavlake.com 43.6532 41.2619 -79.3832 -95.8608
31 nostr.bilthon.dev 25.8128 -80.2377
32 khatru.nostrver.se 51.8933 4.42083
33 relay.bitcoindistrict.org 43.6532 -79.3832
34 nostr.makibisskey.work 43.6532 -79.3832
35 relay.nostraddress.com 43.6532 -79.3832
36 relay.jmoose.rocks 60.1699 24.9384
37 relay.davidebtc.me 51.5072 -0.127586
38 a.nos.lol 50.4754 12.3683
39 nostr.tadryanom.me 43.6532 -79.3832
40 relay.nostrdice.com -33.8688 151.209
41 relay.lumina.rocks 49.0291 8.35695
42 relay.goodmorningbitcoin.com 43.6532 -79.3832
43 nostr.rtvslawenia.com 49.4543 11.0746
44 relay.mattybs.lol 43.6532 -79.3832
45 relay-dev.satlantis.io 40.8302 -74.1299
46 nostream.breadslice.com 43.6532 -79.3832
47 relay.fundstr.me nostr.vulpem.com 42.3601 49.4543 -71.0589 11.0746
48 nostr.oxtr.dev nostr.rohoss.com 50.4754 50.1109 12.3683 8.68213
nos.xmark.cc 50.6924 3.20113
nostr.mikoshi.de 50.1109 8.68213
relay.magiccity.live 25.8128 -80.2377
nostr-verified.wellorder.net 45.5201 -122.99
nostr.makibisskey.work 43.6532 -79.3832
wot.nostr.party 36.1627 -86.7816
relay.copylaradio.com 51.223 6.78245
nostr.sathoarder.com 48.5734 7.75211
relay.jeffg.fyi 43.6532 -79.3832
relay.wellorder.net 45.5201 -122.99
nostr.ovia.to 43.6532 -79.3832
black.nostrcity.club 41.8781 -87.6298
relay.nostrcheck.me 43.6532 -79.3832
nostr-relay.cbrx.io 43.6532 -79.3832
nostr-03.dorafactory.org 1.35208 103.82
nostr-relay.zimage.com 34.282 -118.439
relay.sigit.io 50.4754 12.3683
relay.laantungir.net -19.4692 -42.5315
relay-admin.thaliyal.com 40.8218 -74.45
relay.mwaters.net 50.9871 2.12554
relay.lumina.rocks 49.0291 8.35695
nostr.satstralia.com 64.1476 -21.9392
nostr.sagaciousd.com 49.2827 -123.121
relay.bullishbounty.com 43.6532 -79.3832
wot.dergigi.com 64.1476 -21.9392
relay.vrtmrz.net 43.6532 -79.3832
nostr.0x7e.xyz 47.4988 8.72369
49 articles.layer3.news 37.3387 -121.885
50 relay.digitalezukunft.cyou nos.lol 45.5019 50.4754 -73.5674 12.3683
51 nostr.notribe.net relay.artx.market 40.8302 43.652 -74.1299 -79.3633
52 nostr.21crypto.ch wot.sebastix.social 47.4988 51.8933 8.72369 4.42083
relay.lifpay.me 1.35208 103.82
relay.g1sms.fr 43.9432 2.07537
relay.snort.social 43.6532 -79.3832
relay.getsafebox.app 43.6532 -79.3832
relay.moinsen.com 50.4754 12.3683
nostr.blankfors.se 60.1699 24.9384
nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
orangesync.tech 50.1109 8.68213
relay.ru.ac.th 13.7584 100.622
orangepiller.org 60.1699 24.9384
relay.nostr.band 60.1699 24.9384
wot.soundhsa.com 34.0479 -118.256
khatru.nostrver.se 51.8933 4.42083
nostr.hifish.org 47.4043 8.57398
freelay.sovbit.host 64.1476 -21.9392
nostr.bilthon.dev 25.8128 -80.2377
nostr.night7.space 50.4754 12.3683
relay.illuminodes.com 47.6061 -122.333
relayone.soundhsa.com 34.0479 -118.256
nostr.azzamo.net 52.2633 21.0283
fenrir-s.notoshi.win 43.6532 -79.3832
nostr.spicyz.io 43.6532 -79.3832
nostrelay.memory-art.xyz 43.6532 -79.3832
nostr.coincards.com 53.5501 -113.469
53 alien.macneilmediagroup.com 43.6532 -79.3832
54 nostrings-relay-dev.fly.dev relay.unknown.cloud 41.8781 43.6532 -87.6298 -79.3832
55 relay.satlantis.io nostr.lojong.info 32.8769 43.6532 -80.0114 -79.3832
srtrelay.c-stellar.net 43.6532 -79.3832
relay.agora.social 50.7383 15.0648
relay.bitcoindistrict.org 43.6532 -79.3832
relay.0xchat.com 1.35208 103.82
wot.utxo.one 43.6532 -79.3832
relay.bitcoinartclock.com 50.4754 12.3683
56 nostr.zenon.network 43.5009 -70.4428
57 wot.codingarena.top orangesync.tech 50.4754 50.1109 12.3683 8.68213
58 nostr-02.dorafactory.org nostr.davidebtc.me 1.35208 51.5072 103.82 -0.127586
59 relay.nostromo.social internationalright-wing.org 49.4543 -22.5022 11.0746 -48.7114
60 theoutpost.life nostr.rikmeijer.nl 64.1476 50.4754 -21.9392 12.3683
61 strfry.shock.network ynostr.yael.at 41.8959 60.1699 -88.2169 24.9384
strfry.openhoofd.nl 51.9229 4.40833
itanostr.space 52.2931 4.79099
relay.barine.co 43.6532 -79.3832
nostr.middling.mydns.jp 35.8099 140.12
relay.olas.app 50.4754 12.3683
nostr.girino.org 43.6532 -79.3832
nostr-relay.online 43.6532 -79.3832
relay.evanverma.com 40.8302 -74.1299
relay.angor.io 48.1046 11.6002
relay.nostrdice.com -33.8688 151.209
ribo.us.nostria.app 41.5868 -93.625
relay01.lnfi.network 39.0997 -94.5786
nostr.namek.link 43.6532 -79.3832
relay.aloftus.io 34.0881 -118.379
62 ithurtswhenip.ee 51.223 6.78245
63 ribo.af.nostria.app relay.wellorder.net -26.2041 45.5201 28.0473 -122.99
64 shu05.shugur.net nostr.sathoarder.com 48.8566 48.5734 2.35222 7.75211
65 noxir.kpherox.dev purplerelay.com 34.8587 50.1109 135.509 8.68213
66 relay.degmods.com yabu.me 50.4754 35.6092 12.3683 139.73
relay.goodmorningbitcoin.com 43.6532 -79.3832
nostr.4rs.nl 49.0291 8.35696
nostr-relay.psfoundation.info 39.0438 -77.4874
relay.hasenpfeffr.com 39.0438 -77.4874
fanfares.nostr1.com 40.7128 -74.006
relay.nosto.re 51.8933 4.42083
nostr.liberty.fans 36.9104 -89.5875
nostr-2.21crypto.ch 47.4988 8.72369
relay-rpi.edufeed.org 49.4543 11.0746
offchain.pub 36.1809 -115.241
67 nostr.88mph.life 43.6532 -79.3832
68 nostr.luisschwab.net nostr.overmind.lol 43.6532 -79.3832
69 nostr.casa21.space rnostr.breadslice.com 43.6532 -79.3832
nostr.hekster.org 37.3986 -121.964
relay.utxo.farm 35.6916 139.768
70 zap.watch 45.5029 -73.5723
71 nostr-pub.wellorder.net wot.basspistol.org 45.5201 49.4521 -122.99 11.0767
72 wot.sovbit.host shu01.shugur.net 64.1466 21.4902 -21.9426 39.2246
73 relay.endfiat.money relay.electriclifestyle.com 43.6532 26.2897 -79.3832 -80.1293
74 nostr2.girino.org relay.mccormick.cx 43.6532 52.3563 -79.3832 4.95714
75 nostr.jerrynya.fun nostr.middling.mydns.jp 31.2304 35.8099 121.474 140.12
76 nostr.spacecitynode.com nostr.smut.cloud 29.7057 43.6532 -95.2706 -79.3832
77 satsage.xyz 37.3986 -121.964
78 srtrelay.c-stellar.net 43.6532 -79.3832
79 nostr.0x7e.xyz 47.4988 8.72369
80 shu02.shugur.net 21.4902 39.2246
81 nostrelites.org 41.8781 -87.6298
82 relay-admin.thaliyal.com 40.8218 -74.45
83 wot.soundhsa.com 34.0479 -118.256
84 nostrcheck.me 43.6532 -79.3832
85 relay.nostrhub.tech 49.4543 11.0746
86 relay.stream.labs.h3.se 59.4016 17.9455
87 nostrelay.memory-art.xyz 43.6532 -79.3832
88 nostr.n7ekb.net 47.4941 -122.294
89 relay.nosto.re 51.8933 4.42083
90 nostr.girino.org 43.6532 -79.3832
91 relay.siamdev.cc 13.9178 100.424
relay.notoshi.win 13.7829 100.546
relay.nostx.io 43.6532 -79.3832
relay.cypherflow.ai 48.8566 2.35222
relay.letsfo.com 51.098 17.0321
librerelay.aaroniumii.com 43.6532 -79.3832
gnostr.com 40.9017 29.1616
nostr.pleb.one 38.6327 -90.1961
92 nostr.mehdibekhtaoui.com 49.4939 -1.54813
93 nostr.tadryanom.me orangepiller.org 43.6532 60.1699 -79.3832 24.9384
94 relay.orangepill.ovh nostr.plantroon.com 49.1689 50.1013 -0.358841 8.62643
95 nostr.stakey.net nostr-verified.wellorder.net 52.3676 45.5201 4.90414 -122.99
96 nostr.rtvslawenia.com relay.primal.net 49.4543 43.6532 11.0746 -79.3832
97 relay.bitcoinveneto.org 64.1466 -21.9426
98 relay.hasenpfeffr.com 39.0438 -77.4874
99 strfry.openhoofd.nl 51.9229 4.40833
100 relay.aloftus.io 34.0881 -118.379
101 nostr.spaceshell.xyz 43.6532 -79.3832
102 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
103 ribo.af.nostria.app -26.2041 28.0473
104 nostr.tac.lol 47.4748 -122.273
105 relay.satlantis.io 32.8769 -80.0114
106 nostr.azzamo.net 52.2633 21.0283
107 strfry.bonsai.com 37.8715 -122.273
108 relay.agora.social 50.7383 15.0648
109 nostr-relay.amethyst.name 39.0067 -77.4291
110 relay.toastr.net 40.8054 -74.0241
111 nostr.thebiglake.org 32.71 -96.6745
112 nostr-relay.nextblockvending.com 47.674 -122.122
113 vitor.nostr1.com 40.7057 -74.0136
114 relay.btcforplebs.com 43.6532 -79.3832
115 relay.g1sms.fr 43.9432 2.07537
116 nostr.jfischer.org 49.0291 8.35696
117 nostr.mikoshi.de 52.52 13.405
118 relay.notoshi.win 13.7829 100.546
119 pyramid.fiatjaf.com 50.1109 8.68213
120 relay.coinos.io 43.6532 -79.3832
121 relay.freeplace.nl 52.3676 4.90414
122 nostr-relay.psfoundation.info 39.0438 -77.4874
123 relay.copylaradio.com 51.223 6.78245
124 relay.exit.pub 50.4754 12.3683
125 freelay.sovbit.host 64.1476 -21.9392
126 nostr.satstralia.com 64.1476 -21.9392
127 nostr.l484.com 30.2944 -97.6223
128 nostr.rblb.it 43.4633 11.8796
129 nostr.2b9t.xyz 34.0549 -118.243
130 nostr.dlsouza.lol 50.1109 8.68213
131 strfry.shock.network 41.8959 -88.2169
132 offchain.pub 36.1809 -115.241
133 nostr-01.yakihonne.com 1.32123 103.695
134 nostr.kungfu-g.rip 33.7946 -84.4488
135 relay.letsfo.com 51.098 17.0321
136 relay.lifpay.me 1.35208 103.82
137 relay.damus.io 43.6532 -79.3832
138 dev-relay.lnfi.network relay2.angor.io 39.0997 48.1046 -94.5786 11.6002
139 slick.mjex.me relayrs.notoshi.win 39.048 43.6532 -77.4817 -79.3832
140 wot.sudocarlos.com relay2.ngengine.org 51.5072 43.6532 -0.127586 -79.3832
141 relay04.lnfi.network portal-relay.pareto.space 39.0997 49.4543 -94.5786 11.0746
142 inbox.azzamo.net 52.2633 21.0283
143 nostr-dev.wellorder.net 45.5201 -122.99
144 nostr.stakey.net 52.3676 4.90414
145 relay.13room.space 43.6532 -79.3832
146 relay.conduit.market relay.fountain.fm 43.6532 39.0997 -79.3832 -94.5786
147 black.nostrcity.club 41.8781 -87.6298
148 nostr-2.21crypto.ch 47.4988 8.72369
149 dev-nostr.bityacht.io 25.0797 121.234
150 santo.iguanatech.net 40.8302 -74.1299
151 relay.angor.io 48.1046 11.6002
152 relay.tagayasu.xyz 43.6715 -79.38
153 relay.npubhaus.com 43.6532 -79.3832
154 relay01.lnfi.network 39.0997 -94.5786
155 nostr.myshosholoza.co.za 52.3676 4.90414
156 relay02.lnfi.network 39.0997 -94.5786
157 gnostr.com 40.9017 29.1616
158 nostr.sagaciousd.com 49.2827 -123.121
159 nostr.night7.space 50.4754 12.3683
160 schnorr.me 43.6532 -79.3832
161 nostr.blankfors.se 60.1699 24.9384
162 relay.mostro.network 40.8302 -74.1299
163 purpura.cloud 43.6532 -79.3832
164 ribo.eu.nostria.app 52.3676 4.90414
165 vidono.apps.slidestr.net 48.8566 2.35222
166 wheat.happytavern.co 43.6532 -79.3832
167 nostr.faultables.net 43.6532 -79.3832
168 relay5.bitransfer.org 43.6532 -79.3832
169 relay.nostrhub.fr 48.1046 11.6002
170 nostr.thaliyal.com 40.8218 -74.45
171 relay.holzeis.me 43.6532 -79.3832
172 relay.nostriot.com 41.5695 -83.9786
173 nostr.openhoofd.nl 51.9229 4.40833
174 relay.nostr.vet 52.6467 4.7395
175 nostr.camalolo.com 24.1469 120.684
176 relay.origin.land 35.6673 139.751
177 relay.chakany.systems 43.6532 -79.3832
178 relay.0xchat.com 1.35208 103.82
179 nostr.mom 50.4754 12.3683
180 4u2ni0zjbjvni.clorecloud.net 43.6532 -79.3832
181 prl.plus 55.7623 37.6381
182 relay.moinsen.com 50.4754 12.3683
183 nostr-02.czas.top 53.471 9.88208
184 relay.sigit.io 50.4754 12.3683
185 relay.nostrcheck.me 43.6532 -79.3832
186 relay03.lnfi.network 39.0997 -94.5786
187 relay.sincensura.org 43.6532 -79.3832
188 nostr.coincards.com 53.5501 -113.469
189 nostr-03.dorafactory.org 1.35208 103.82
190 relay.credenso.cafe 43.1149 -80.7228
191 nostr.fbxl.net 48.3809 -89.2477
192 relay.bullishbounty.com 43.6532 -79.3832
193 nos.xmark.cc 50.6924 3.20113
194 x.kojira.io 43.6532 -79.3832
195 wot.sovbit.host 64.1466 -21.9426
196 shu05.shugur.net 48.8566 2.35222
197 nostr.carroarmato0.be 50.9928 3.26317
198 relay.cosmicbolt.net 37.3986 -121.964
199 r.bitcoinhold.net 43.6532 -79.3832
200 nostr.diakod.com 43.6532 -79.3832
201 nostr-relay.cbrx.io 43.6532 -79.3832
202 nostr.coincrowd.fund 39.0438 -77.4874
203 cyberspace.nostr1.com 40.7128 -74.006
204 relay.barine.co 43.6532 -79.3832
205 relay.orangepill.ovh 49.1689 -0.358841
206 no.str.cr 9.92857 -84.0528
207 nostr.casa21.space 43.6532 -79.3832
208 relay.mwaters.net 50.9871 2.12554
209 relay.magiccity.live 25.8128 -80.2377
210 relayone.soundhsa.com 34.0479 -118.256
211 slick.mjex.me 39.048 -77.4817
212 relay.utxo.farm 35.6916 139.768
213 theoutpost.life 64.1476 -21.9392
214 nostr.hekster.org 37.3986 -121.964
215 strfry.felixzieger.de 50.1013 8.62643
216 relay.mess.ch 47.3591 8.55292
217 wot.codingarena.top 50.4754 12.3683
218 nostrelay.circum.space 51.2217 6.77616
219 nostr-relay.online 43.6532 -79.3832
220 temp.iris.to 43.6532 -79.3832
221 wot.dergigi.com 64.1476 -21.9392
222 wot.brightbolt.net 47.6735 -116.781
223 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
224 wot.nostr.place 30.2672 -97.7431
225 ribo.us.nostria.app 41.5868 -93.625
226 relay.nostr.net 50.4754 12.3683
227 nostr-02.dorafactory.org 1.35208 103.82
228 relay.tapestry.ninja 40.8054 -74.0241
229 adre.su 59.9311 30.3609
230 librerelay.aaroniumii.com 43.6532 -79.3832
231 nostr-pub.wellorder.net 45.5201 -122.99
232 kitchen.zap.cooking 43.6532 -79.3832
233 nostr.21crypto.ch 47.4988 8.72369
234 nostr-02.yakihonne.com 1.32123 103.695
235 relay.javi.space 43.4633 11.8796
236 nostr.ser1.net 12.9716 77.5946
237 relay-rpi.edufeed.org 49.4543 11.0746
238 premium.primal.net 43.6532 -79.3832
239 relay.degmods.com 50.4754 12.3683
240 relay.arx-ccn.com 50.4754 12.3683
241 nostr.chaima.info 51.223 6.78245
242 relay.illuminodes.com 47.6061 -122.333
243 relay.nostx.io 43.6532 -79.3832
244 relay.puresignal.news 43.6532 -79.3832
245 fenrir-s.notoshi.win 43.6532 -79.3832
246 relay.getsafebox.app 43.6532 -79.3832
247 relay.conduit.market 43.6532 -79.3832
248 relay.jeffg.fyi 43.6532 -79.3832
249 nproxy.kristapsk.lv 60.1699 24.9384
250 relay.olas.app 50.4754 12.3683
251 relay.dwadziesciajeden.pl 52.2297 21.0122
252 relay-testnet.k8s.layer3.news 37.3387 -121.885
253 nostr.pleb.one 38.6327 -90.1961
254 relay.digitalezukunft.cyou 45.5019 -73.5674
255 relay.evanverma.com 40.8302 -74.1299
256 wot.dtonon.com 43.6532 -79.3832
257 relay.seq1.net 43.6532 -79.3832
258 nostr.kalf.org 52.3676 4.90414
259 nostr.snowbla.de 60.1699 24.9384
260 nostr.spicyz.io 43.6532 -79.3832
261 nostr-relay.zimage.com 34.282 -118.439
262 nostr.spacecitynode.com 29.7057 -95.2706
263 dev-relay.lnfi.network 39.0997 -94.5786
264 itanostr.space 52.2931 4.79099