mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 07:45:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd2da18f5 | ||
|
|
c49a1b264e | ||
|
|
10c0391eaf | ||
|
|
3a94b57341 | ||
|
|
f8f780d2d6 | ||
|
|
c837afb818 | ||
|
|
47ef82f01a | ||
|
|
d1e5ce21a7 | ||
|
|
f2c1bb2131 | ||
|
|
221819b591 | ||
|
|
4a21ab0531 |
@@ -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)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
> [!WARNING]
|
> [!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](http://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](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
## License
|
## 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)
|
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
|
|||||||
Generated
+2
-2
@@ -498,7 +498,7 @@
|
|||||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
@@ -684,7 +684,7 @@
|
|||||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "image-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "image-1024 1.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "image-1024 2.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
+1
-1
@@ -3,4 +3,4 @@
|
|||||||
"author" : "xcode",
|
"author" : "xcode",
|
||||||
"version" : 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,23 +158,6 @@ struct IdentityCache: Codable {
|
|||||||
var version: Int = 1
|
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
|
// MARK: - Migration Support
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
//
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,6 +41,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
private var allowDefaultRelays: Bool = false
|
private var allowDefaultRelays: Bool = false
|
||||||
|
private var hasMutualFavorites: Bool = false
|
||||||
|
private var hasLocationPermission: Bool = false
|
||||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||||
@@ -82,17 +84,27 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
allowDefaultRelays = hasMutual
|
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||||
if hasMutual {
|
applyDefaultRelayPolicy(force: true)
|
||||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
|
||||||
}
|
|
||||||
// Deterministic JSON shape for outbound requests
|
// Deterministic JSON shape for outbound requests
|
||||||
self.encoder.outputFormatting = .sortedKeys
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
FavoritesPersistenceService.shared.$mutualFavorites
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] favorites in
|
.sink { [weak self] favorites in
|
||||||
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
|
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()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
@@ -326,10 +338,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateDefaultRelayPolicy(hasMutual: Bool) {
|
private func applyDefaultRelayPolicy(force: Bool = false) {
|
||||||
guard hasMutual != allowDefaultRelays else { return }
|
let shouldAllow = hasMutualFavorites || hasLocationPermission
|
||||||
allowDefaultRelays = hasMutual
|
if !force && shouldAllow == allowDefaultRelays { return }
|
||||||
if hasMutual {
|
allowDefaultRelays = shouldAllow
|
||||||
|
if shouldAllow {
|
||||||
var existing = Set(relays.map { $0.url })
|
var existing = Set(relays.map { $0.url })
|
||||||
for url in Self.defaultRelays where !existing.contains(url) {
|
for url in Self.defaultRelays where !existing.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
|
|||||||
@@ -7,6 +7,78 @@ import CryptoKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#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
|
/// BLEService — Bluetooth Mesh Transport
|
||||||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||||||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||||||
@@ -40,6 +112,7 @@ final class BLEService: NSObject {
|
|||||||
var isConnecting: Bool = false
|
var isConnecting: Bool = false
|
||||||
var isConnected: Bool = false
|
var isConnected: Bool = false
|
||||||
var lastConnectionAttempt: Date? = nil
|
var lastConnectionAttempt: Date? = nil
|
||||||
|
var assembler = NotificationStreamAssembler()
|
||||||
}
|
}
|
||||||
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
|
private var peripherals: [String: PeripheralState] = [:] // UUID -> PeripheralState
|
||||||
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
|
private var peerToPeripheralUUID: [String: String] = [:] // PeerID -> Peripheral UUID
|
||||||
@@ -191,7 +264,9 @@ final class BLEService: NSObject {
|
|||||||
// MARK: - Helpers: IDs, selection, and write backpressure
|
// MARK: - Helpers: IDs, selection, and write backpressure
|
||||||
private func makeMessageID(for packet: BitchatPacket) -> String {
|
private func makeMessageID(for packet: BitchatPacket) -> String {
|
||||||
let senderID = packet.senderID.hexEncodedString()
|
let senderID = packet.senderID.hexEncodedString()
|
||||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
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)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func subsetSizeForFanout(_ n: Int) -> Int {
|
private func subsetSizeForFanout(_ n: Int) -> Int {
|
||||||
@@ -1110,6 +1185,13 @@ final class BLEService: NSObject {
|
|||||||
// Determine last-hop link for this message to avoid echoing back
|
// Determine last-hop link for this message to avoid echoing back
|
||||||
let messageID = makeMessageID(for: packet)
|
let messageID = makeMessageID(for: packet)
|
||||||
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
|
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()
|
let states = snapshotPeripheralStates()
|
||||||
var minCentralWriteLen: Int?
|
var minCentralWriteLen: Int?
|
||||||
@@ -1163,7 +1245,7 @@ final class BLEService: NSObject {
|
|||||||
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
|
// Special-case control/presence messages: do NOT subset to maximize immediate coverage
|
||||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||||
if directedOnlyPeer == nil
|
if directedPeerHint == nil
|
||||||
&& packet.type != MessageType.fragment.rawValue
|
&& packet.type != MessageType.fragment.rawValue
|
||||||
&& packet.type != MessageType.announce.rawValue
|
&& packet.type != MessageType.announce.rawValue
|
||||||
&& packet.type != MessageType.requestSync.rawValue {
|
&& packet.type != MessageType.requestSync.rawValue {
|
||||||
@@ -1174,7 +1256,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If directed and we currently have no links to forward on, spool for a short window
|
// If directed and we currently have no links to forward on, spool for a short window
|
||||||
if let only = directedOnlyPeer,
|
if let only = directedPeerHint,
|
||||||
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
||||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
||||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||||
@@ -2515,7 +2597,8 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
peerID: nil,
|
peerID: nil,
|
||||||
isConnecting: true,
|
isConnecting: true,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
lastConnectionAttempt: Date()
|
lastConnectionAttempt: Date(),
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
)
|
)
|
||||||
peripheral.delegate = self
|
peripheral.delegate = self
|
||||||
|
|
||||||
@@ -2564,7 +2647,9 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
characteristic: nil,
|
characteristic: nil,
|
||||||
peerID: nil,
|
peerID: nil,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
isConnected: true
|
isConnected: true,
|
||||||
|
lastConnectionAttempt: nil,
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2702,7 +2787,8 @@ extension BLEService {
|
|||||||
peerID: nil,
|
peerID: nil,
|
||||||
isConnecting: true,
|
isConnecting: true,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
lastConnectionAttempt: Date()
|
lastConnectionAttempt: Date(),
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
)
|
)
|
||||||
peripheral.delegate = self
|
peripheral.delegate = self
|
||||||
let options: [String: Any] = [
|
let options: [String: Any] = [
|
||||||
@@ -2839,53 +2925,72 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = characteristic.value else {
|
guard let data = characteristic.value, !data.isEmpty else {
|
||||||
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Received BLE notification
|
bufferNotificationChunk(data, from: peripheral)
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the packet's senderID as the peer identifier
|
|
||||||
let senderID = packet.senderID.hexEncodedString()
|
|
||||||
// Only log non-announce packets
|
|
||||||
if packet.type != MessageType.announce.rawValue {
|
|
||||||
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
|
||||||
let peripheralUUID = peripheral.identifier.uuidString
|
let peripheralUUID = peripheral.identifier.uuidString
|
||||||
|
|
||||||
// Update mapping ONLY for announce packets that come directly from the peer (not relayed)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let senderID = packet.senderID.hexEncodedString()
|
||||||
|
|
||||||
|
if packet.type != MessageType.announce.rawValue {
|
||||||
|
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
if packet.type == MessageType.announce.rawValue {
|
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 packet.ttl == messageTTL {
|
||||||
if var state = peripherals[peripheralUUID] {
|
if var state = peripherals[peripheralUUID] {
|
||||||
state.peerID = senderID
|
state.peerID = senderID
|
||||||
peripherals[peripheralUUID] = state
|
peripherals[peripheralUUID] = state
|
||||||
}
|
}
|
||||||
peerToPeripheralUUID[senderID] = peripheralUUID
|
peerToPeripheralUUID[senderID] = peripheralUUID
|
||||||
// Mapping update - direct announce from peer
|
|
||||||
}
|
}
|
||||||
// Record ingress link for last-hop suppression and process
|
|
||||||
let msgID = makeMessageID(for: packet)
|
let msgID = makeMessageID(for: packet)
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||||
}
|
}
|
||||||
// Process the announce packet regardless of whether we updated the mapping
|
|
||||||
handleReceivedPacket(packet, from: senderID)
|
handleReceivedPacket(packet, from: senderID)
|
||||||
} else {
|
} 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)
|
let msgID = makeMessageID(for: packet)
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
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).
|
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesCounter: ObservableObject {
|
final class LocationNotesCounter: ObservableObject {
|
||||||
@@ -8,29 +37,45 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
@Published private(set) var geohash: String? = nil
|
@Published private(set) var geohash: String? = nil
|
||||||
@Published private(set) var count: Int? = 0
|
@Published private(set) var count: Int? = 0
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
@Published private(set) var relayAvailable: Bool = true
|
||||||
|
|
||||||
private var subscriptionID: String? = nil
|
private var subscriptionID: String? = nil
|
||||||
private var noteIDs = Set<String>()
|
private var noteIDs = Set<String>()
|
||||||
|
private let dependencies: LocationNotesCounterDependencies
|
||||||
|
|
||||||
private init() {}
|
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
init(testDependencies: LocationNotesCounterDependencies) {
|
||||||
|
self.dependencies = testDependencies
|
||||||
|
}
|
||||||
|
|
||||||
func subscribe(geohash gh: String) {
|
func subscribe(geohash gh: String) {
|
||||||
let norm = gh.lowercased()
|
let norm = gh.lowercased()
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
// Unsubscribe previous without clearing count to avoid flicker
|
// Unsubscribe previous without clearing count to avoid flicker
|
||||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
|
relayAvailable = true
|
||||||
|
|
||||||
// Subscribe only to the building geohash (precision 8)
|
// Subscribe only to the building geohash (precision 8)
|
||||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
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
|
subscriptionID = subID
|
||||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
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 let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue 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 }
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||||
@@ -38,16 +83,17 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
self.count = self.noteIDs.count
|
self.count = self.noteIDs.count
|
||||||
}
|
}
|
||||||
}, onEOSE: { [weak self] in
|
}, { [weak self] in
|
||||||
self?.initialLoadComplete = true
|
self?.initialLoadComplete = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
geohash = nil
|
geohash = nil
|
||||||
count = 0
|
count = 0
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
|
relayAvailable = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,57 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
/// 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).
|
||||||
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesManager: ObservableObject {
|
final class LocationNotesManager: ObservableObject {
|
||||||
|
enum State: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case ready
|
||||||
|
case noRelays
|
||||||
|
}
|
||||||
|
|
||||||
struct Note: Identifiable, Equatable {
|
struct Note: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let pubkey: String
|
let pubkey: String
|
||||||
@@ -24,10 +71,14 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
||||||
@Published private(set) var geohash: String
|
@Published private(set) var geohash: String
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
@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 var subscriptionID: String?
|
||||||
|
private let dependencies: LocationNotesDependencies
|
||||||
|
|
||||||
init(geohash: String) {
|
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||||
self.geohash = geohash.lowercased()
|
self.geohash = geohash.lowercased()
|
||||||
|
self.dependencies = dependencies
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +86,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
geohash = norm
|
geohash = norm
|
||||||
@@ -43,15 +94,43 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
notes.removeAll()
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func subscribe() {
|
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 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
|
subscriptionID = subID
|
||||||
|
initialLoadComplete = false
|
||||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
|
||||||
let relayUrls: [String]? = relays.isEmpty ? nil : relays
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
initialLoadComplete = false
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag
|
// Ensure matching tag
|
||||||
@@ -62,8 +141,13 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
self.notes.append(note)
|
self.notes.append(note)
|
||||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
}, onEOSE: { [weak self] in
|
self.state = .ready
|
||||||
self?.initialLoadComplete = true
|
}, { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.initialLoadComplete = true
|
||||||
|
if self.state != .noRelays {
|
||||||
|
self.state = .ready
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,29 +155,46 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
func send(content: String, nickname: String) {
|
func send(content: String, nickname: String) {
|
||||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
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 {
|
do {
|
||||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
let id = try dependencies.deriveIdentity(geohash)
|
||||||
let event = try NostrProtocol.createGeohashTextNote(
|
let event = try NostrProtocol.createGeohashTextNote(
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
geohash: geohash,
|
geohash: geohash,
|
||||||
senderIdentity: id,
|
senderIdentity: id,
|
||||||
nickname: nickname
|
nickname: nickname
|
||||||
)
|
)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
dependencies.sendEvent(event, relays)
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
|
||||||
// Optimistic local-echo
|
// Optimistic local-echo
|
||||||
let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname)
|
let echo = Note(
|
||||||
|
id: event.id,
|
||||||
|
pubkey: id.publicKeyHex,
|
||||||
|
content: trimmed,
|
||||||
|
createdAt: dependencies.now(),
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
self.notes.insert(echo, at: 0)
|
self.notes.insert(echo, at: 0)
|
||||||
|
self.state = .ready
|
||||||
|
self.errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||||
|
errorMessage = "Failed to send note. \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicitly cancel subscription and release resources.
|
/// Explicitly cancel subscription and release resources.
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
state = .idle
|
||||||
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@
|
|||||||
/// ## Integration Points
|
/// ## Integration Points
|
||||||
/// - **BLEService**: Calls this service for all private messages
|
/// - **BLEService**: Calls this service for all private messages
|
||||||
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
||||||
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
|
||||||
/// - **KeychainManager**: Secure storage for identity keys
|
/// - **KeychainManager**: Secure storage for identity keys
|
||||||
///
|
///
|
||||||
/// ## Thread Safety
|
/// ## Thread Safety
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ struct RelayController {
|
|||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
|
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||||
|
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
if ttlCap <= 1 || senderIsSelf {
|
||||||
|
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||||
|
}
|
||||||
|
|
||||||
// For session-critical or directed traffic, be deterministic and reliable
|
// For session-critical or directed traffic, be deterministic and reliable
|
||||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
||||||
// Always relay with no TTL cap for these types
|
// Always relay with no TTL cap for these types
|
||||||
let newTTL = (ttl &- 1)
|
let newTTL = ttlCap &- 1
|
||||||
// Slight jitter to desynchronize without adding too much latency
|
// Slight jitter to desynchronize without adding too much latency
|
||||||
// Tighter for faster multi-hop handshakes and directed DMs
|
// Tighter for faster multi-hop handshakes and directed DMs
|
||||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||||
@@ -32,28 +36,17 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
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
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep very low to avoid floods
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
// - Announces get a bit more headroom
|
||||||
// - Announces in sparse graphs get a bit more headroom
|
let ttlLimit: UInt8 = {
|
||||||
let ttlCap: UInt8 = {
|
if degree >= highDegreeThreshold {
|
||||||
if degree >= highDegreeThreshold { return 3 }
|
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||||
return isAnnounce ? 7 : 6
|
}
|
||||||
|
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||||
|
return max(UInt8(2), min(ttlCap, preferred))
|
||||||
}()
|
}()
|
||||||
let clamped = max(1, min(ttl, ttlCap))
|
let newTTL = ttlLimit &- 1
|
||||||
let newTTL = clamped &- 1
|
|
||||||
|
|
||||||
// Wider jitter window to allow duplicate suppression to win more often
|
// Wider jitter window to allow duplicate suppression to win more often
|
||||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
||||||
@@ -64,6 +57,6 @@ struct RelayController {
|
|||||||
case 6...9: delayMs = Int.random(in: 80...180)
|
case 6...9: delayMs = Int.random(in: 80...180)
|
||||||
default: delayMs = Int.random(in: 100...220)
|
default: delayMs = Int.random(in: 100...220)
|
||||||
}
|
}
|
||||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ enum TransportConfig {
|
|||||||
static let uiWindowStepCount: Int = 200
|
static let uiWindowStepCount: Int = 200
|
||||||
|
|
||||||
// Share extension
|
// Share extension
|
||||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import CryptoKit
|
|||||||
// Golomb-Coded Set (GCS) filter utilities for sync.
|
// Golomb-Coded Set (GCS) filter utilities for sync.
|
||||||
// Hashing:
|
// Hashing:
|
||||||
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
|
// - 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 [0, M) via (h64 % M).
|
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
|
||||||
// Encoding (v1):
|
// Encoding (v1):
|
||||||
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
|
// - 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).
|
// - 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,25 +29,40 @@ enum GCSFilter {
|
|||||||
|
|
||||||
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||||
let p = deriveP(targetFpr: targetFpr)
|
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 cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
||||||
let n = min(ids.count, cap)
|
let selected = Array(ids.prefix(cap))
|
||||||
let selected = Array(ids.prefix(n))
|
let range = max(1, hashRange(count: selected.count, p: p))
|
||||||
// Map to [0, M)
|
let modulo = UInt64(range)
|
||||||
let mInit = UInt32(n << p)
|
|
||||||
var mapped = selected.map { id16 -> UInt64 in
|
var mapped = selected
|
||||||
let h = h64(id16)
|
.map { h64($0) }
|
||||||
return UInt64(h % UInt64(max(1, mInit)))
|
.map { mapHash($0, modulo: modulo) }
|
||||||
}.sorted()
|
.sorted()
|
||||||
|
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||||
|
|
||||||
|
if mapped.isEmpty {
|
||||||
|
return Params(p: p, m: range, data: Data())
|
||||||
|
}
|
||||||
|
|
||||||
var encoded = encode(sorted: mapped, p: p)
|
var encoded = encode(sorted: mapped, p: p)
|
||||||
var trimmedN = n
|
var trimmedCount = mapped.count
|
||||||
// Trim if over budget
|
|
||||||
while encoded.count > maxBytes && trimmedN > 0 {
|
while encoded.count > maxBytes && trimmedCount > 0 {
|
||||||
trimmedN = (trimmedN * 9) / 10 // drop ~10%
|
if trimmedCount == 1 {
|
||||||
mapped = Array(mapped.prefix(trimmedN))
|
mapped.removeAll()
|
||||||
|
encoded = Data()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
||||||
|
mapped = Array(mapped.prefix(trimmedCount))
|
||||||
encoded = encode(sorted: mapped, p: p)
|
encoded = encode(sorted: mapped, p: p)
|
||||||
}
|
}
|
||||||
let finalM = UInt32(max(1, trimmedN << p))
|
|
||||||
return Params(p: p, m: finalM, data: encoded)
|
return Params(p: p, m: range, data: encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||||
@@ -77,6 +92,12 @@ enum GCSFilter {
|
|||||||
return false
|
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 {
|
private static func h64(_ id16: Data) -> UInt64 {
|
||||||
var hasher = SHA256()
|
var hasher = SHA256()
|
||||||
hasher.update(data: id16)
|
hasher.update(data: id16)
|
||||||
@@ -88,6 +109,39 @@ enum GCSFilter {
|
|||||||
return x & 0x7fff_ffff_ffff_ffff
|
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 {
|
private static func encode(sorted: [UInt64], p: Int) -> Data {
|
||||||
let writer = BitWriter()
|
let writer = BitWriter()
|
||||||
var prev: UInt64 = 0
|
var prev: UInt64 = 0
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// Gossip-based sync manager using on-demand GCS filters
|
// Gossip-based sync manager using on-demand GCS filters
|
||||||
final class GossipSyncManager {
|
final class GossipSyncManager {
|
||||||
@@ -53,6 +52,12 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onPublicPacketSeen(_ packet: BitchatPacket) {
|
func onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._onPublicPacketSeen(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
guard let r = packet.recipientID else { return true }
|
guard let r = packet.recipientID else { return true }
|
||||||
@@ -119,18 +124,17 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
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
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
var hasher = SHA256()
|
let bucket = GCSFilter.bucket(for: id, modulus: request.m)
|
||||||
hasher.update(data: id) // 16-byte PacketId
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
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
|
// 1) Announcements: send latest per peer if requester lacks them
|
||||||
@@ -182,9 +186,15 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: String) {
|
func removeAnnouncementForPeer(_ peerID: String) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._removeAnnouncementForPeer(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _removeAnnouncementForPeer(_ peerID: String) {
|
||||||
let normalizedPeerID = peerID.lowercased()
|
let normalizedPeerID = peerID.lowercased()
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
|
|
||||||
// Remove messages from this peer
|
// Remove messages from this peer
|
||||||
// Collect IDs to remove first to avoid concurrent modification
|
// Collect IDs to remove first to avoid concurrent modification
|
||||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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,7 +8,9 @@ struct InputValidator {
|
|||||||
|
|
||||||
struct Limits {
|
struct Limits {
|
||||||
static let maxNicknameLength = 50
|
static let maxNicknameLength = 50
|
||||||
static let maxMessageLength = 10_000
|
// 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 maxReasonLength = 200
|
static let maxReasonLength = 200
|
||||||
static let maxPeerIDLength = 64
|
static let maxPeerIDLength = 64
|
||||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,7 @@ struct AppInfoView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { dismiss() }) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
@@ -106,16 +106,29 @@ struct AppInfoView: View {
|
|||||||
// Header
|
// Header
|
||||||
VStack(alignment: .center, spacing: 8) {
|
VStack(alignment: .center, spacing: 8) {
|
||||||
Text(Strings.appName)
|
Text(Strings.appName)
|
||||||
.font(.system(size: 32, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(Strings.tagline)
|
Text(Strings.tagline)
|
||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical)
|
.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
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.Features.title)
|
SectionHeader(Strings.Features.title)
|
||||||
@@ -162,26 +175,13 @@ struct AppInfoView: View {
|
|||||||
description: Strings.Privacy.panic.2)
|
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
|
// Warning
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
SectionHeader(Strings.Warning.title)
|
SectionHeader(Strings.Warning.title)
|
||||||
.foregroundColor(Color.red)
|
.foregroundColor(Color.red)
|
||||||
|
|
||||||
Text(Strings.Warning.message)
|
Text(Strings.Warning.message)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(Color.red)
|
.foregroundColor(Color.red)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -211,7 +211,7 @@ struct SectionHeader: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
@@ -234,17 +234,17 @@ struct FeatureRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 30)
|
.frame(width: 30)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(description)
|
Text(description)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -254,6 +254,16 @@ struct FeatureRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview("Default") {
|
||||||
AppInfoView()
|
AppInfoView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("Dynamic Type XXL") {
|
||||||
|
AppInfoView()
|
||||||
|
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Dynamic Type XS") {
|
||||||
|
AppInfoView()
|
||||||
|
.environment(\.sizeCategory, .extraSmall)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ struct ContentView: View {
|
|||||||
@State private var textFieldSelection: NSRange? = nil
|
@State private var textFieldSelection: NSRange? = nil
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@State private var showPeerList = false
|
@State private var showPeerList = false
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var sidebarDragOffset: CGFloat = 0
|
@State private var sidebarDragOffset: CGFloat = 0
|
||||||
@@ -53,6 +54,7 @@ struct ContentView: View {
|
|||||||
@State private var showLocationNotes = false
|
@State private var showLocationNotes = false
|
||||||
@State private var notesGeohash: String? = nil
|
@State private var notesGeohash: String? = nil
|
||||||
@State private var sheetNotesCount: Int = 0
|
@State private var sheetNotesCount: Int = 0
|
||||||
|
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
||||||
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
// Timer-based refresh removed; use LocationChannelManager live updates instead
|
||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@@ -63,14 +65,18 @@ struct ContentView: View {
|
|||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
colorScheme == .dark ? Color.black : Color.white
|
colorScheme == .dark ? Color.black : Color.white
|
||||||
}
|
}
|
||||||
|
|
||||||
private var textColor: Color {
|
private var textColor: Color {
|
||||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var secondaryTextColor: Color {
|
private var secondaryTextColor: Color {
|
||||||
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
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
|
// MARK: - Body
|
||||||
|
|
||||||
@@ -328,7 +334,7 @@ struct ContentView: View {
|
|||||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
else { expandedMessageIDs.insert(message.id) }
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
}
|
}
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(Color.blue)
|
.foregroundColor(Color.blue)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
@@ -706,7 +712,7 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(suggestion)
|
Text(suggestion)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -764,14 +770,14 @@ struct ContentView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
// Show all aliases together
|
// Show all aliases together
|
||||||
Text(info.commands.joined(separator: ", "))
|
Text(info.commands.joined(separator: ", "))
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
|
||||||
// Show syntax if any
|
// Show syntax if any
|
||||||
if let syntax = info.syntax {
|
if let syntax = info.syntax {
|
||||||
Text(syntax)
|
Text(syntax)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,7 +785,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// Show description
|
// Show description
|
||||||
Text(info.description)
|
Text(info.description)
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -802,7 +808,7 @@ struct ContentView: View {
|
|||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
TextField("type a message...", text: $messageText)
|
TextField("type a message...", text: $messageText)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.focused($isTextFieldFocused)
|
.focused($isTextFieldFocused)
|
||||||
.padding(.leading, 12)
|
.padding(.leading, 12)
|
||||||
@@ -877,7 +883,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
Button(action: sendMessage) {
|
Button(action: sendMessage) {
|
||||||
Image(systemName: "arrow.up.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(messageText.isEmpty ? Color.gray :
|
.foregroundColor(messageText.isEmpty ? Color.gray :
|
||||||
viewModel.selectedPrivateChatPeer != nil
|
viewModel.selectedPrivateChatPeer != nil
|
||||||
? Color.orange : textColor)
|
? Color.orange : textColor)
|
||||||
@@ -918,20 +924,20 @@ struct ContentView: View {
|
|||||||
// Header - match main toolbar height
|
// Header - match main toolbar height
|
||||||
HStack {
|
HStack {
|
||||||
Text("PEOPLE")
|
Text("PEOPLE")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
// Show QR in mesh on all platforms
|
// Show QR in mesh on all platforms
|
||||||
if case .mesh = locationManager.selectedChannel {
|
if case .mesh = locationManager.selectedChannel {
|
||||||
Button(action: { showVerifySheet = true }) {
|
Button(action: { showVerifySheet = true }) {
|
||||||
Image(systemName: "qrcode")
|
Image(systemName: "qrcode")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Verification: show my QR or scan a friend")
|
.help("Verification: show my QR or scan a friend")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44) // Match header height
|
.frame(height: headerHeight) // Match header height
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
|
|
||||||
@@ -1082,7 +1088,7 @@ struct ContentView: View {
|
|||||||
private var mainHeaderView: some View {
|
private var mainHeaderView: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("bitchat/")
|
Text("bitchat/")
|
||||||
.font(.system(size: 18, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
// PANIC: Triple-tap to clear all data
|
// PANIC: Triple-tap to clear all data
|
||||||
@@ -1095,12 +1101,12 @@ struct ContentView: View {
|
|||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("@")
|
Text("@")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
|
|
||||||
TextField("nickname", text: $viewModel.nickname)
|
TextField("nickname", text: $viewModel.nickname)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.frame(maxWidth: 80)
|
.frame(maxWidth: 80)
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.focused($isNicknameFieldFocused)
|
.focused($isNicknameFieldFocused)
|
||||||
@@ -1139,7 +1145,7 @@ struct ContentView: View {
|
|||||||
if viewModel.hasAnyUnreadMessages {
|
if viewModel.hasAnyUnreadMessages {
|
||||||
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
|
||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -1159,7 +1165,7 @@ struct ContentView: View {
|
|||||||
let currentCount = (notesCounter.count ?? 0)
|
let currentCount = (notesCounter.count ?? 0)
|
||||||
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
||||||
Image(systemName: "long.text.page.and.pencil")
|
Image(systemName: "long.text.page.and.pencil")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
.foregroundColor(hasNotes ? textColor : Color.gray)
|
||||||
.padding(.top, 1)
|
.padding(.top, 1)
|
||||||
}
|
}
|
||||||
@@ -1173,7 +1179,7 @@ struct ContentView: View {
|
|||||||
if case .location(let ch) = locationManager.selectedChannel {
|
if case .location(let ch) = locationManager.selectedChannel {
|
||||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||||
@@ -1196,9 +1202,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
Text(badgeText)
|
Text(badgeText)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(badgeColor)
|
.foregroundColor(badgeColor)
|
||||||
.lineLimit(1)
|
.lineLimit(headerLineLimit)
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.layoutPriority(2)
|
.layoutPriority(2)
|
||||||
.accessibilityLabel("location channels")
|
.accessibilityLabel("location channels")
|
||||||
@@ -1210,15 +1216,15 @@ struct ContentView: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
// People icon with count
|
// People icon with count
|
||||||
Image(systemName: "person.2.fill")
|
Image(systemName: "person.2.fill")
|
||||||
.font(.system(size: 11))
|
.font(.bitchatSystem(size: 11))
|
||||||
.accessibilityLabel("\(headerOtherPeersCount) people")
|
.accessibilityLabel("\(headerOtherPeersCount) people")
|
||||||
Text("\(headerOtherPeersCount)")
|
Text("\(headerOtherPeersCount)")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(headerCountColor)
|
.foregroundColor(headerCountColor)
|
||||||
.padding(.leading, 2)
|
.padding(.leading, 2)
|
||||||
.lineLimit(1)
|
.lineLimit(headerLineLimit)
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
|
||||||
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
// QR moved to the PEOPLE header in the sidebar when on mesh channel
|
||||||
@@ -1235,7 +1241,7 @@ struct ContentView: View {
|
|||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||||
@@ -1251,22 +1257,22 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("notes")
|
Text("notes")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: { showLocationNotes = false }) {
|
Button(action: { showLocationNotes = false }) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Close")
|
.accessibilityLabel("Close")
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
Text("location unavailable")
|
Text("location unavailable")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
Button("enable location") {
|
Button("enable location") {
|
||||||
LocationChannelManager.shared.enableLocationChannels()
|
LocationChannelManager.shared.enableLocationChannels()
|
||||||
@@ -1417,19 +1423,19 @@ struct ContentView: View {
|
|||||||
case .bluetoothConnected:
|
case .bluetoothConnected:
|
||||||
// Radio icon for mesh connection
|
// Radio icon for mesh connection
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel("Connected via mesh")
|
||||||
case .meshReachable:
|
case .meshReachable:
|
||||||
// point.3 filled icon for reachable via mesh (not directly connected)
|
// point.3 filled icon for reachable via mesh (not directly connected)
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Reachable via mesh")
|
.accessibilityLabel("Reachable via mesh")
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
// Purple globe for Nostr
|
// Purple globe for Nostr
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel("Available via Nostr")
|
.accessibilityLabel("Available via Nostr")
|
||||||
case .offline:
|
case .offline:
|
||||||
@@ -1439,25 +1445,25 @@ struct ContentView: View {
|
|||||||
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
||||||
// Fallback: reachable via mesh but not in current peer list
|
// Fallback: reachable via mesh but not in current peer list
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Reachable via mesh")
|
.accessibilityLabel("Reachable via mesh")
|
||||||
} else if isNostrAvailable {
|
} else if isNostrAvailable {
|
||||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
.accessibilityLabel("Available via Nostr")
|
.accessibilityLabel("Available via Nostr")
|
||||||
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
|
} else if viewModel.meshService.isPeerConnected(headerPeerID) || viewModel.connectedPeers.contains(headerPeerID) {
|
||||||
// Fallback: if peer lookup is missing but mesh reports connected, show radio
|
// Fallback: if peer lookup is missing but mesh reports connected, show radio
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
Image(systemName: "dot.radiowaves.left.and.right")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel("Connected via mesh")
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("\(privatePeerNick)")
|
Text("\(privatePeerNick)")
|
||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
||||||
@@ -1470,7 +1476,7 @@ struct ContentView: View {
|
|||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
||||||
encryptionStatus == .noiseSecured ? textColor :
|
encryptionStatus == .noiseSecured ? textColor :
|
||||||
Color.red)
|
Color.red)
|
||||||
@@ -1492,7 +1498,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 44, height: 44, alignment: .leading)
|
.frame(width: 44, height: 44, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -1508,7 +1514,7 @@ struct ContentView: View {
|
|||||||
viewModel.toggleFavorite(peerID: headerPeerID)
|
viewModel.toggleFavorite(peerID: headerPeerID)
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
|
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
|
||||||
.font(.system(size: 16))
|
.font(.bitchatSystem(size: 16))
|
||||||
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -1517,7 +1523,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
}
|
}
|
||||||
@@ -1574,7 +1580,7 @@ private struct PaymentChipView: View {
|
|||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(emoji)
|
Text(emoji)
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -1615,20 +1621,20 @@ struct DeliveryStatusView: View {
|
|||||||
switch status {
|
switch status {
|
||||||
case .sending:
|
case .sending:
|
||||||
Image(systemName: "circle")
|
Image(systemName: "circle")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
case .sent:
|
case .sent:
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
case .delivered(let nickname, _):
|
case .delivered(let nickname, _):
|
||||||
HStack(spacing: -2) {
|
HStack(spacing: -2) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor.opacity(0.8))
|
.foregroundColor(textColor.opacity(0.8))
|
||||||
.help("Delivered to \(nickname)")
|
.help("Delivered to \(nickname)")
|
||||||
@@ -1636,25 +1642,25 @@ struct DeliveryStatusView: View {
|
|||||||
case .read(let nickname, _):
|
case .read(let nickname, _):
|
||||||
HStack(spacing: -2) {
|
HStack(spacing: -2) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||||
}
|
}
|
||||||
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||||
.help("Read by \(nickname)")
|
.help("Read by \(nickname)")
|
||||||
|
|
||||||
case .failed(let reason):
|
case .failed(let reason):
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(Color.red.opacity(0.8))
|
.foregroundColor(Color.red.opacity(0.8))
|
||||||
.help("Failed: \(reason)")
|
.help("Failed: \(reason)")
|
||||||
|
|
||||||
case .partiallyDelivered(let reached, let total):
|
case .partiallyDelivered(let reached, let total):
|
||||||
HStack(spacing: 1) {
|
HStack(spacing: 1) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
Text("\(reached)/\(total)")
|
Text("\(reached)/\(total)")
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
}
|
}
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
.help("Delivered to \(reached) of \(total) members")
|
.help("Delivered to \(reached) of \(total) members")
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ struct FingerprintView: View {
|
|||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
Text("SECURITY VERIFICATION")
|
Text("SECURITY VERIFICATION")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { dismiss() }) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
@@ -66,17 +66,17 @@ struct FingerprintView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(peerNickname)
|
Text(peerNickname)
|
||||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(encryptionStatus.description)
|
Text(encryptionStatus.description)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +89,12 @@ struct FingerprintView: View {
|
|||||||
// Their fingerprint
|
// Their fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("THEIR FINGERPRINT:")
|
Text("THEIR FINGERPRINT:")
|
||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -115,7 +115,7 @@ struct FingerprintView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("not available - handshake in progress")
|
Text("not available - handshake in progress")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -124,12 +124,12 @@ struct FingerprintView: View {
|
|||||||
// My fingerprint
|
// My fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("YOUR FINGERPRINT:")
|
Text("YOUR FINGERPRINT:")
|
||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
let myFingerprint = viewModel.getMyFingerprint()
|
let myFingerprint = viewModel.getMyFingerprint()
|
||||||
Text(formatFingerprint(myFingerprint))
|
Text(formatFingerprint(myFingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -156,14 +156,14 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(isVerified ? Color.green : Color.orange)
|
.foregroundColor(isVerified ? Color.green : Color.orange)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Text(isVerified ?
|
Text(isVerified ?
|
||||||
"you have verified this person's identity." :
|
"you have verified this person's identity." :
|
||||||
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -176,7 +176,7 @@ struct FingerprintView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("MARK AS VERIFIED")
|
Text("MARK AS VERIFIED")
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -190,7 +190,7 @@ struct FingerprintView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("REMOVE VERIFICATION")
|
Text("REMOVE VERIFICATION")
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ struct GeohashPeopleList: View {
|
|||||||
if viewModel.visibleGeohashPeople().isEmpty {
|
if viewModel.visibleGeohashPeople().isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text("nobody around...")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
@@ -51,30 +51,30 @@ struct GeohashPeopleList: View {
|
|||||||
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
||||||
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
||||||
let rowColor: Color = isMe ? .orange : assignedColor
|
let rowColor: Color = isMe ? .orange : assignedColor
|
||||||
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
|
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||||
|
|
||||||
let (base, suffix) = splitSuffix(from: person.displayName)
|
let (base, suffix) = splitSuffix(from: person.displayName)
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.fontWeight(isMe ? .bold : .regular)
|
.fontWeight(isMe ? .bold : .regular)
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
if !suffix.isEmpty {
|
if !suffix.isEmpty {
|
||||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
||||||
Text(suffix)
|
Text(suffix)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(suffixColor)
|
.foregroundColor(suffixColor)
|
||||||
}
|
}
|
||||||
if isMe {
|
if isMe {
|
||||||
Text(" (you)")
|
Text(" (you)")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let me = myHex, person.id != me {
|
if let me = myHex, person.id != me {
|
||||||
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked in geochash")
|
.help("Blocked in geochash")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,14 @@ struct LocationChannelsSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("#location channels")
|
HStack(spacing: 12) {
|
||||||
.font(.system(size: 18, design: .monospaced))
|
Text("#location channels")
|
||||||
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
closeButton
|
||||||
|
}
|
||||||
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.")
|
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(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
@@ -31,7 +35,7 @@ struct LocationChannelsSheet: View {
|
|||||||
case LocationChannelManager.PermissionState.notDetermined:
|
case LocationChannelManager.PermissionState.notDetermined:
|
||||||
Button(action: { manager.enableLocationChannels() }) {
|
Button(action: { manager.enableLocationChannels() }) {
|
||||||
Text("get location and my geohashes")
|
Text("get location and my geohashes")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -42,7 +46,7 @@ struct LocationChannelsSheet: View {
|
|||||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("location permission denied. enable in settings to use location channels.")
|
Text("location permission denied. enable in settings to use location channels.")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("open settings") { openSystemLocationSettings() }
|
Button("open settings") { openSystemLocationSettings() }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -60,29 +64,9 @@ struct LocationChannelsSheet: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.navigationBarHidden(true)
|
||||||
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
|
#else
|
||||||
.toolbar {
|
.navigationTitle("")
|
||||||
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
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -112,6 +96,16 @@ struct LocationChannelsSheet: View {
|
|||||||
.onChange(of: manager.availableChannels) { _ in }
|
.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 {
|
private var channelList: some View {
|
||||||
List {
|
List {
|
||||||
// Mesh option first (no bookmark)
|
// Mesh option first (no bookmark)
|
||||||
@@ -119,6 +113,7 @@ struct LocationChannelsSheet: View {
|
|||||||
manager.select(ChannelID.mesh)
|
manager.select(ChannelID.mesh)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
|
|
||||||
// Nearby options
|
// Nearby options
|
||||||
if !manager.availableChannels.isEmpty {
|
if !manager.availableChannels.isEmpty {
|
||||||
@@ -137,7 +132,7 @@ struct LocationChannelsSheet: View {
|
|||||||
trailingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
@@ -148,20 +143,22 @@ struct LocationChannelsSheet: View {
|
|||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text("finding nearby channels…")
|
Text("finding nearby channels…")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom geohash teleport
|
// Custom geohash teleport
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Text("#")
|
Text("#")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
TextField("geohash", text: $customGeohash)
|
TextField("geohash", text: $customGeohash)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -169,7 +166,7 @@ struct LocationChannelsSheet: View {
|
|||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.keyboardType(.asciiCapable)
|
.keyboardType(.asciiCapable)
|
||||||
#endif
|
#endif
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.onChange(of: customGeohash) { newValue in
|
.onChange(of: customGeohash) { newValue in
|
||||||
// Allow only geohash base32 characters, strip '#', limit length
|
// Allow only geohash base32 characters, strip '#', limit length
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
@@ -197,14 +194,13 @@ struct LocationChannelsSheet: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text("teleport")
|
Text("teleport")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
Image(systemName: "face.dashed")
|
Image(systemName: "face.dashed")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color.secondary.opacity(0.12))
|
.background(Color.secondary.opacity(0.12))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
@@ -213,16 +209,17 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
if let err = customError {
|
if let err = customError {
|
||||||
Text(err)
|
Text(err)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
|
|
||||||
// Bookmarked geohashes
|
// Bookmarked geohashes
|
||||||
if !bookmarks.bookmarks.isEmpty {
|
if !bookmarks.bookmarks.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("bookmarked")
|
Text("bookmarked")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||||
@@ -239,7 +236,7 @@ struct LocationChannelsSheet: View {
|
|||||||
trailingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(gh) }) {
|
Button(action: { bookmarks.toggle(gh) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
@@ -263,16 +260,18 @@ struct LocationChannelsSheet: View {
|
|||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer action inside the list
|
// Footer action inside the list
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
torToggleSection
|
torToggleSection
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0))
|
||||||
Button(action: {
|
Button(action: {
|
||||||
openSystemLocationSettings()
|
openSystemLocationSettings()
|
||||||
}) {
|
}) {
|
||||||
Text("remove location access")
|
Text("remove location access")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -282,6 +281,7 @@ struct LocationChannelsSheet: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
@@ -319,12 +319,12 @@ struct LocationChannelsSheet: View {
|
|||||||
let parts = splitTitleAndCount(title)
|
let parts = splitTitleAndCount(title)
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(parts.base)
|
Text(parts.base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.fontWeight(titleBold ? .bold : .regular)
|
.fontWeight(titleBold ? .bold : .regular)
|
||||||
.foregroundColor(titleColor ?? Color.primary)
|
.foregroundColor(titleColor ?? Color.primary)
|
||||||
if let count = parts.countSuffix, !count.isEmpty {
|
if let count = parts.countSuffix, !count.isEmpty {
|
||||||
Text(count)
|
Text(count)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,7 @@ struct LocationChannelsSheet: View {
|
|||||||
return subtitlePrefix
|
return subtitlePrefix
|
||||||
}()
|
}()
|
||||||
Text(subtitleFull)
|
Text(subtitleFull)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
@@ -343,7 +343,7 @@ struct LocationChannelsSheet: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Text("✔︎")
|
Text("✔︎")
|
||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
}
|
}
|
||||||
trailingAccessory()
|
trailingAccessory()
|
||||||
@@ -425,10 +425,10 @@ extension LocationChannelsSheet {
|
|||||||
Toggle(isOn: torToggleBinding) {
|
Toggle(isOn: torToggleBinding) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("tor routing")
|
Text("tor routing")
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
Text("hides your ip for location channels. recommended: on.")
|
Text("hides your ip for location channels. recommended: on.")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,7 +459,7 @@ private struct IRCToggleStyle: ToggleStyle {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Text(configuration.isOn ? "on" : "off")
|
Text(configuration.isOn ? "on" : "off")
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct LocationNotesView: View {
|
|||||||
let onNotesCountChanged: ((Int) -> Void)?
|
let onNotesCountChanged: ((Int) -> Void)?
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var draft: String = ""
|
@State private var draft: String = ""
|
||||||
@@ -18,30 +19,24 @@ struct LocationNotesView: View {
|
|||||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||||
colorScheme == .dark ? Color.black : Color.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 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 {
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
ScrollView {
|
||||||
Divider()
|
VStack(spacing: 0) {
|
||||||
list
|
headerSection
|
||||||
Divider()
|
notesContent
|
||||||
input
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
inputSection
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.foregroundColor(textColor)
|
|
||||||
.onDisappear { manager.cancel() }
|
.onDisappear { manager.cancel() }
|
||||||
.onChange(of: geohash) { newValue in
|
.onChange(of: geohash) { newValue in
|
||||||
manager.setGeohash(newValue)
|
manager.setGeohash(newValue)
|
||||||
@@ -50,100 +45,202 @@ struct LocationNotesView: View {
|
|||||||
.onChange(of: manager.notes.count) { newValue in
|
.onChange(of: manager.notes.count) { newValue in
|
||||||
onNotesCountChanged?(newValue)
|
onNotesCountChanged?(newValue)
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
NavigationView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerSection
|
||||||
|
ScrollView {
|
||||||
|
notesContent
|
||||||
|
}
|
||||||
|
.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 header: some View {
|
private var closeButton: some View {
|
||||||
HStack {
|
Button(action: { dismiss() }) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Image(systemName: "xmark")
|
||||||
HStack(spacing: 4) {
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
let c = manager.notes.count
|
.frame(width: 32, height: 32)
|
||||||
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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button(action: { dismiss() }) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 12)
|
.accessibilityLabel("Close")
|
||||||
.background(backgroundColor.opacity(0.95))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var list: some View {
|
private var headerSection: some View {
|
||||||
ScrollView {
|
let count = manager.notes.count
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(manager.notes) { note in
|
HStack(spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text("#\(geohash) • \(count) \(count == 1 ? "note" : "notes")")
|
||||||
HStack(spacing: 6) {
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
// Show @name without the #abcd suffix; timestamp in brackets
|
Spacer()
|
||||||
HStack(spacing: 0) {
|
closeButton
|
||||||
Text("@")
|
}
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
if let building = locationManager.locationNames[.building], !building.isEmpty {
|
||||||
.foregroundColor(textColor)
|
Text(building)
|
||||||
let parts = splitSuffix(from: note.displayName)
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
Text(parts.0)
|
.foregroundColor(accentGreen)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
|
||||||
.foregroundColor(textColor)
|
Text(block)
|
||||||
}
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
let ts = timestampText(for: note.createdAt)
|
.foregroundColor(accentGreen)
|
||||||
Text(ts.isEmpty ? "" : "[\(ts)]")
|
}
|
||||||
.font(.system(size: 11, design: .monospaced))
|
Text("add short permanent notes to this location for other visitors to find.")
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
.foregroundColor(.secondary)
|
||||||
Text(note.content)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
if manager.state == .loading && !manager.initialLoadComplete {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Text("loading recent notes…")
|
||||||
}
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.padding(.horizontal, 12)
|
.foregroundColor(.secondary)
|
||||||
}
|
} else if manager.state == .noRelays {
|
||||||
|
Text("geo relays unavailable; notes paused")
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var input: some View {
|
private var notesContent: some View {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
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 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("dismiss") { manager.clearError() }
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inputSection: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
TextField("add a note for this place", text: $draft, axis: .vertical)
|
TextField("add a note for this place", text: $draft, axis: .vertical)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||||
.padding(.horizontal, 12)
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
Button(action: send) {
|
Button(action: send) {
|
||||||
Image(systemName: "arrow.up.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor)
|
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.disabled(!sendButtonEnabled)
|
||||||
.padding(.trailing, 12)
|
|
||||||
}
|
}
|
||||||
.frame(minHeight: 44)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 14)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor)
|
||||||
|
.overlay(Divider(), alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send() {
|
private func send() {
|
||||||
@@ -153,15 +250,17 @@ struct LocationNotesView: View {
|
|||||||
draft = ""
|
draft = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sendButtonEnabled: Bool {
|
||||||
|
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Timestamp Formatting
|
// MARK: - Timestamp Formatting
|
||||||
private func timestampText(for date: Date) -> String {
|
private func timestampText(for date: Date) -> String {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
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) ?? ""
|
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||||
return rel.isEmpty ? "" : "\(rel) ago"
|
return rel.isEmpty ? "" : "\(rel) ago"
|
||||||
} else {
|
} else {
|
||||||
// Absolute date (MMM d or MMM d, yyyy if different year)
|
|
||||||
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
||||||
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
||||||
return fmt.string(from: date)
|
return fmt.string(from: date)
|
||||||
@@ -189,16 +288,3 @@ struct LocationNotesView: View {
|
|||||||
return f
|
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 {
|
if viewModel.allPeers.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text("nobody around...")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
@@ -45,27 +45,27 @@ struct MeshPeerList: View {
|
|||||||
let baseColor = isMe ? Color.orange : assigned
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isConnected {
|
} else if peer.isConnected {
|
||||||
// Mesh-connected peer: radio icon
|
// Mesh-connected peer: radio icon
|
||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isReachable {
|
} else if peer.isReachable {
|
||||||
// Mesh-reachable (relayed): point.3 icon
|
// Mesh-reachable (relayed): point.3 icon
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isMutualFavorite {
|
} else if peer.isMutualFavorite {
|
||||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
} else {
|
} else {
|
||||||
// Fallback icon for others (dimmed)
|
// Fallback icon for others (dimmed)
|
||||||
Image(systemName: "person")
|
Image(systemName: "person")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,19 +73,19 @@ struct MeshPeerList: View {
|
|||||||
let (base, suffix) = splitSuffix(from: displayName)
|
let (base, suffix) = splitSuffix(from: displayName)
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
if !suffix.isEmpty {
|
if !suffix.isEmpty {
|
||||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
||||||
Text(suffix)
|
Text(suffix)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(suffixColor)
|
.foregroundColor(suffixColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked")
|
.help("Blocked")
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ struct MeshPeerList: View {
|
|||||||
if peer.isConnected {
|
if peer.isConnected {
|
||||||
if let icon = item.enc.icon {
|
if let icon = item.enc.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -102,12 +102,12 @@ struct MeshPeerList: View {
|
|||||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
if let fp = viewModel.getFingerprint(for: peer.id),
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if let icon = item.enc.icon {
|
} else if let icon = item.enc.icon {
|
||||||
// Fallback to whatever status says (likely lock if we had a past session)
|
// Fallback to whatever status says (likely lock if we had a past session)
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ struct MeshPeerList: View {
|
|||||||
// Unread message indicator for this peer
|
// Unread message indicator for this peer
|
||||||
if !isMe, item.hasUnread {
|
if !isMe, item.hasUnread {
|
||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.help("New messages")
|
.help("New messages")
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ struct MeshPeerList: View {
|
|||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.id) }) {
|
Button(action: { onToggleFavorite(peer.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ struct MyQRView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("scan to verify me")
|
Text("scan to verify me")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
QRCodeImage(data: qrString, size: 240)
|
QRCodeImage(data: qrString, size: 240)
|
||||||
@@ -24,7 +24,7 @@ struct MyQRView: View {
|
|||||||
|
|
||||||
// Non-scrolling, fully visible URL (wraps across lines)
|
// Non-scrolling, fully visible URL (wraps across lines)
|
||||||
Text(qrString)
|
Text(qrString)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -60,7 +60,7 @@ struct QRCodeImage: View {
|
|||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(
|
.overlay(
|
||||||
Text("qr unavailable")
|
Text("qr unavailable")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ struct QRScanView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
#else
|
#else
|
||||||
Text("paste qr content to validate:")
|
Text("paste qr content to validate:")
|
||||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
|
||||||
TextEditor(text: $input)
|
TextEditor(text: $input)
|
||||||
.frame(height: 100)
|
.frame(height: 100)
|
||||||
.border(Color.gray.opacity(0.4))
|
.border(Color.gray.opacity(0.4))
|
||||||
@@ -255,7 +255,7 @@ struct VerificationSheetView: View {
|
|||||||
// Top header (always at top)
|
// Top header (always at top)
|
||||||
HStack {
|
HStack {
|
||||||
Text("VERIFY")
|
Text("VERIFY")
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -263,7 +263,7 @@ struct VerificationSheetView: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -279,7 +279,7 @@ struct VerificationSheetView: View {
|
|||||||
if showingScanner {
|
if showingScanner {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("scan a friend's qr")
|
Text("scan a friend's qr")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
@@ -310,13 +310,13 @@ struct VerificationSheetView: View {
|
|||||||
if showingScanner {
|
if showingScanner {
|
||||||
Button(action: { showingScanner = false }) {
|
Button(action: { showingScanner = false }) {
|
||||||
Label("show my qr", systemImage: "qrcode")
|
Label("show my qr", systemImage: "qrcode")
|
||||||
.font(.system(size: 13, design: .monospaced))
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
} else {
|
} else {
|
||||||
Button(action: { showingScanner = true }) {
|
Button(action: { showingScanner = true }) {
|
||||||
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.gray)
|
.tint(.gray)
|
||||||
@@ -328,7 +328,7 @@ struct VerificationSheetView: View {
|
|||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||||
Label("remove verification", systemImage: "minus.circle")
|
Label("remove verification", systemImage: "minus.circle")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.gray)
|
.tint(.gray)
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ final class ShareViewController: UIViewController {
|
|||||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||||
])
|
])
|
||||||
|
DispatchQueue.global().async {
|
||||||
processShare()
|
self.processShare()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Processing
|
// MARK: - Processing
|
||||||
@@ -163,7 +164,7 @@ final class ShareViewController: UIViewController {
|
|||||||
statusLabel.text = msg
|
statusLabel.text = msg
|
||||||
// Complete shortly after showing status
|
// Complete shortly after showing status
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
||||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+264
-253
@@ -1,264 +1,275 @@
|
|||||||
Relay URL,Latitude,Longitude
|
Relay URL,Latitude,Longitude
|
||||||
relay.laantungir.net,-19.4692,-42.5315
|
nostr.tac.lol,47.4748,-122.273
|
||||||
relay.endfiat.money,43.6532,-79.3832
|
relay.javi.space,43.4633,11.8796
|
||||||
relay.zone667.com,60.1699,24.9384
|
|
||||||
shu04.shugur.net,25.2604,55.2989
|
|
||||||
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
|
|
||||||
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
|
nostr.einundzwanzig.space,50.1109,8.68213
|
||||||
relay.21e6.cz,50.1682,14.0546
|
nostr.kalf.org,52.3676,4.90414
|
||||||
relay04.lnfi.network,39.0997,-94.5786
|
wot.dtonon.com,43.6532,-79.3832
|
||||||
relay.chorus.community,50.1109,8.68213
|
nostr-01.yakihonne.com,1.32123,103.695
|
||||||
relay.nostr.place,32.7767,-96.797
|
wot.basspistol.org,49.4521,11.0767
|
||||||
relay.vrtmrz.net,43.6532,-79.3832
|
relay.puresignal.news,43.6532,-79.3832
|
||||||
noxir.kpherox.dev,34.8587,135.509
|
nostrelites.org,41.8781,-87.6298
|
||||||
wot.nostr.net,43.6532,-79.3832
|
nostr-relay.nextblockvending.com,47.674,-122.122
|
||||||
relay.cypherflow.ai,48.8566,2.35222
|
relay03.lnfi.network,39.0997,-94.5786
|
||||||
wot.sudocarlos.com,51.5072,-0.127586
|
nproxy.kristapsk.lv,60.1699,24.9384
|
||||||
nostr.jerrynya.fun,31.2304,121.474
|
relay.toastr.net,40.8054,-74.0241
|
||||||
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
|
relay.davidebtc.me,51.5072,-0.127586
|
||||||
a.nos.lol,50.4754,12.3683
|
wot.nostr.net,43.6532,-79.3832
|
||||||
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.mattybs.lol,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
|
||||||
|
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
|
||||||
relay-dev.satlantis.io,40.8302,-74.1299
|
relay-dev.satlantis.io,40.8302,-74.1299
|
||||||
nostream.breadslice.com,43.6532,-79.3832
|
nostream.breadslice.com,43.6532,-79.3832
|
||||||
nostr.vulpem.com,49.4543,11.0746
|
relay.fundstr.me,42.3601,-71.0589
|
||||||
nostr.rohoss.com,50.1109,8.68213
|
nostr.oxtr.dev,50.4754,12.3683
|
||||||
articles.layer3.news,37.3387,-121.885
|
|
||||||
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
|
|
||||||
relay.unknown.cloud,43.6532,-79.3832
|
|
||||||
nostr.lojong.info,43.6532,-79.3832
|
|
||||||
nostr.zenon.network,43.5009,-70.4428
|
|
||||||
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
|
|
||||||
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.overmind.lol,43.6532,-79.3832
|
|
||||||
rnostr.breadslice.com,43.6532,-79.3832
|
|
||||||
zap.watch,45.5029,-73.5723
|
|
||||||
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
|
|
||||||
nostr.mehdibekhtaoui.com,49.4939,-1.54813
|
|
||||||
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
|
|
||||||
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.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
|
nos.xmark.cc,50.6924,3.20113
|
||||||
x.kojira.io,43.6532,-79.3832
|
nostr.mikoshi.de,50.1109,8.68213
|
||||||
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
|
relay.magiccity.live,25.8128,-80.2377
|
||||||
relayone.soundhsa.com,34.0479,-118.256
|
nostr-verified.wellorder.net,45.5201,-122.99
|
||||||
slick.mjex.me,39.048,-77.4817
|
nostr.makibisskey.work,43.6532,-79.3832
|
||||||
relay.utxo.farm,35.6916,139.768
|
wot.nostr.party,36.1627,-86.7816
|
||||||
theoutpost.life,64.1476,-21.9392
|
relay.copylaradio.com,51.223,6.78245
|
||||||
nostr.hekster.org,37.3986,-121.964
|
nostr.sathoarder.com,48.5734,7.75211
|
||||||
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
|
relay.jeffg.fyi,43.6532,-79.3832
|
||||||
nproxy.kristapsk.lv,60.1699,24.9384
|
relay.wellorder.net,45.5201,-122.99
|
||||||
relay.olas.app,50.4754,12.3683
|
nostr.ovia.to,43.6532,-79.3832
|
||||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
black.nostrcity.club,41.8781,-87.6298
|
||||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
relay.nostrcheck.me,43.6532,-79.3832
|
||||||
nostr.pleb.one,38.6327,-90.1961
|
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||||
relay.digitalezukunft.cyou,45.5019,-73.5674
|
nostr-03.dorafactory.org,1.35208,103.82
|
||||||
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-relay.zimage.com,34.282,-118.439
|
||||||
nostr.spacecitynode.com,29.7057,-95.2706
|
relay.sigit.io,50.4754,12.3683
|
||||||
dev-relay.lnfi.network,39.0997,-94.5786
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
relay.13room.space,43.6532,-79.3832
|
||||||
|
relay.conduit.market,43.6532,-79.3832
|
||||||
|
nostr.chaima.info,51.223,6.78245
|
||||||
|
|||||||
|
Reference in New Issue
Block a user