mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:45:22 +00:00
Compare commits
16
Commits
v1.4.3
..
peer-id-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8daacb2dce | ||
|
|
2d8d45a4d7 | ||
|
|
7267fb6404 | ||
|
|
a6d013bee9 | ||
|
|
7b20bdf821 | ||
|
|
9be05f98e9 | ||
|
|
b97241978d | ||
|
|
a454a85d86 | ||
|
|
9ecd346197 | ||
|
|
94e19dc039 | ||
|
|
438357486f | ||
|
|
4a382716bb | ||
|
|
1563b06d73 | ||
|
|
abb7489e2f | ||
|
|
2bc875dee5 | ||
|
|
588cef72ce |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -44,7 +44,7 @@ final class TestHelpers {
|
||||
originalSender: nil,
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: recipientNickname,
|
||||
senderPeerID: senderPeerID,
|
||||
senderPeer: senderPeerID,
|
||||
mentions: mentions
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+247
-258
@@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user