mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d55d766023 | ||
|
|
54499560e8 | ||
|
|
75c8933091 | ||
|
|
d2bfbbcfd4 |
@@ -126,6 +126,10 @@
|
|||||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
|
||||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; };
|
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; };
|
||||||
D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; };
|
D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; };
|
||||||
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; };
|
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; };
|
||||||
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
|
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
|
||||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
|
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
|
||||||
@@ -243,6 +247,8 @@
|
|||||||
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = "<group>"; };
|
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = "<group>"; };
|
||||||
D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = "<group>"; };
|
D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = "<group>"; };
|
||||||
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRelayDirectory.swift; sourceTree = "<group>"; };
|
||||||
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||||
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
|
||||||
E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = "<group>"; };
|
E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = "<group>"; };
|
||||||
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -302,6 +308,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
2F82C5FC8433F4064F079D1F /* bitchat */,
|
2F82C5FC8433F4064F079D1F /* bitchat */,
|
||||||
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
|
||||||
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
@@ -321,8 +328,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
|
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
|
||||||
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
|
|
||||||
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
|
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
|
||||||
|
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
|
||||||
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
|
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
|
||||||
EA706D8E5097785414646A8E /* Info.plist */,
|
EA706D8E5097785414646A8E /* Info.plist */,
|
||||||
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
|
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
|
||||||
@@ -519,6 +526,7 @@
|
|||||||
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
|
||||||
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
|
||||||
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
|
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
|
||||||
|
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */,
|
||||||
);
|
);
|
||||||
path = Nostr;
|
path = Nostr;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -683,6 +691,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
|
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
|
||||||
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -692,6 +701,7 @@
|
|||||||
files = (
|
files = (
|
||||||
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
|
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
|
||||||
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
|
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
|
||||||
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -756,6 +766,7 @@
|
|||||||
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
|
||||||
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
|
||||||
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
|
||||||
|
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -809,6 +820,7 @@
|
|||||||
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
|
||||||
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
|
||||||
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
|
||||||
|
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
|
// Warm up georelay directory and refresh if stale (once/day)
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
|
@MainActor
|
||||||
|
final class GeoRelayDirectory {
|
||||||
|
struct Entry: Hashable {
|
||||||
|
let host: String
|
||||||
|
let lat: Double
|
||||||
|
let lon: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = GeoRelayDirectory()
|
||||||
|
private(set) var entries: [Entry] = []
|
||||||
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
|
private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Load cached or bundled data synchronously
|
||||||
|
self.entries = self.loadLocalEntries()
|
||||||
|
// Fire-and-forget remote refresh if stale
|
||||||
|
prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
|
let center = Geohash.decodeCenter(geohash)
|
||||||
|
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
|
guard !entries.isEmpty else { return [] }
|
||||||
|
let sorted = entries
|
||||||
|
.sorted { a, b in
|
||||||
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
|
}
|
||||||
|
.prefix(count)
|
||||||
|
return sorted.map { "wss://\($0.host)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote Fetch
|
||||||
|
func prefetchIfNeeded() {
|
||||||
|
let now = Date()
|
||||||
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
|
fetchRemote()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRemote() {
|
||||||
|
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
|
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||||
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
|
if !parsed.isEmpty {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.entries = parsed
|
||||||
|
self.persistCache(text)
|
||||||
|
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||||
|
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistCache(_ text: String) {
|
||||||
|
guard let url = cacheURL() else { return }
|
||||||
|
do {
|
||||||
|
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
private func loadLocalEntries() -> [Entry] {
|
||||||
|
// Prefer cached file if present
|
||||||
|
if let cache = self.cacheURL(),
|
||||||
|
let data = try? Data(contentsOf: cache),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
let arr = Self.parseCSV(text)
|
||||||
|
if !arr.isEmpty { return arr }
|
||||||
|
}
|
||||||
|
// Try bundled resource(s)
|
||||||
|
let bundleCandidates = [
|
||||||
|
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||||
|
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||||
|
].compactMap { $0 }
|
||||||
|
for url in bundleCandidates {
|
||||||
|
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||||
|
let arr = Self.parseCSV(text)
|
||||||
|
if !arr.isEmpty { return arr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try filesystem path (development/test)
|
||||||
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
return Self.parseCSV(text)
|
||||||
|
}
|
||||||
|
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
|
var result: Set<Entry> = []
|
||||||
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
|
// Skip header if present
|
||||||
|
for (idx, raw) in lines.enumerated() {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if line.isEmpty { continue }
|
||||||
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
|
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard parts.count >= 3 else { continue }
|
||||||
|
var host = parts[0]
|
||||||
|
host = host.replacingOccurrences(of: "https://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "http://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "wss://", with: "")
|
||||||
|
host = host.replacingOccurrences(of: "ws://", with: "")
|
||||||
|
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
|
}
|
||||||
|
return Array(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheURL() -> URL? {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
|
} catch { return nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Distance
|
||||||
|
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
||||||
|
let r = 6371.0 // Earth radius in km
|
||||||
|
let dLat = (lat2 - lat1) * .pi / 180
|
||||||
|
let dLon = (lon2 - lon1) * .pi / 180
|
||||||
|
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
|
||||||
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
return r * c
|
||||||
|
}
|
||||||
@@ -78,9 +78,23 @@ class NostrRelayManager: ObservableObject {
|
|||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
|
let existing = Set(relays.map { $0.url })
|
||||||
|
for url in Set(relayUrls) {
|
||||||
|
if !existing.contains(url) {
|
||||||
|
relays.append(Relay(url: url))
|
||||||
|
}
|
||||||
|
if connections[url] == nil {
|
||||||
|
connectToRelay(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
let targetRelays = relayUrls ?? relays.map { $0.url }
|
let targetRelays = relayUrls ?? Self.defaultRelays
|
||||||
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
// Add to queue for reliability
|
// Add to queue for reliability
|
||||||
messageQueueLock.lock()
|
messageQueueLock.lock()
|
||||||
@@ -95,10 +109,11 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to events matching a filter
|
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||||
func subscribe(
|
func subscribe(
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
|
relayUrls: [String]? = nil,
|
||||||
handler: @escaping (NostrEvent) -> Void
|
handler: @escaping (NostrEvent) -> Void
|
||||||
) {
|
) {
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
@@ -120,8 +135,14 @@ class NostrRelayManager: ObservableObject {
|
|||||||
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
|
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
|
||||||
// category: SecureLogger.session, level: .debug)
|
// category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Send subscription to all connected relays
|
// Target specific relays if provided; else all connections
|
||||||
for (relayUrl, connection) in connections {
|
let urls = relayUrls ?? Self.defaultRelays
|
||||||
|
ensureConnections(to: urls)
|
||||||
|
let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in
|
||||||
|
connections[url].map { (url, $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (relayUrl, connection) in targets {
|
||||||
connection.send(.string(messageString)) { error in
|
connection.send(.string(messageString)) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import Foundation
|
|||||||
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
||||||
enum Geohash {
|
enum Geohash {
|
||||||
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
private static let base32Map: [Character: Int] = {
|
||||||
|
var map: [Character: Int] = [:]
|
||||||
|
for (i, c) in base32Chars.enumerated() { map[c] = i }
|
||||||
|
return map
|
||||||
|
}()
|
||||||
|
|
||||||
/// Encodes the provided coordinates into a geohash string.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -56,5 +61,30 @@ enum Geohash {
|
|||||||
|
|
||||||
return String(geohash)
|
return String(geohash)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Decodes a geohash into the center latitude/longitude of its bounding box.
|
||||||
|
/// - Parameter geohash: Base32 geohash string.
|
||||||
|
/// - Returns: (lat, lon) center coordinate.
|
||||||
|
static func decodeCenter(_ geohash: String) -> (lat: Double, lon: Double) {
|
||||||
|
var latInterval: (Double, Double) = (-90.0, 90.0)
|
||||||
|
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
||||||
|
|
||||||
|
var isEven = true
|
||||||
|
for ch in geohash.lowercased() {
|
||||||
|
guard let cd = base32Map[ch] else { continue }
|
||||||
|
for mask in [16, 8, 4, 2, 1] {
|
||||||
|
if isEven {
|
||||||
|
let mid = (lonInterval.0 + lonInterval.1) / 2
|
||||||
|
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
|
||||||
|
} else {
|
||||||
|
let mid = (latInterval.0 + latInterval.1) / 2
|
||||||
|
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
|
||||||
|
}
|
||||||
|
isEven.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lat = (latInterval.0 + latInterval.1) / 2
|
||||||
|
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||||
|
return (lat, lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,9 +91,134 @@ import UIKit
|
|||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
class ChatViewModel: ObservableObject, BitchatDelegate {
|
class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||||
|
// Precompiled regexes and detectors reused across formatting
|
||||||
|
private enum Regexes {
|
||||||
|
static let hashtag: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
||||||
|
}()
|
||||||
|
static let mention: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
||||||
|
}()
|
||||||
|
static let cashu: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let bolt11: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lnurl: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lightningScheme: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||||
|
}()
|
||||||
|
static let linkDetector: NSDataDetector? = {
|
||||||
|
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
|
}()
|
||||||
|
static let quickCashuPresence: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let simplifyHTTPURL: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spam resilience: token buckets
|
||||||
|
private struct TokenBucket {
|
||||||
|
var capacity: Double
|
||||||
|
var tokens: Double
|
||||||
|
var refillPerSec: Double
|
||||||
|
var lastRefill: Date
|
||||||
|
|
||||||
|
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
|
||||||
|
let dt = now.timeIntervalSince(lastRefill)
|
||||||
|
if dt > 0 {
|
||||||
|
tokens = min(capacity, tokens + dt * refillPerSec)
|
||||||
|
lastRefill = now
|
||||||
|
}
|
||||||
|
if tokens >= cost {
|
||||||
|
tokens -= cost
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rateBucketsBySender: [String: TokenBucket] = [:]
|
||||||
|
private var rateBucketsByContent: [String: TokenBucket] = [:]
|
||||||
|
private let senderBucketCapacity: Double = 5
|
||||||
|
private let senderBucketRefill: Double = 1 // tokens per second
|
||||||
|
private let contentBucketCapacity: Double = 3
|
||||||
|
private let contentBucketRefill: Double = 0.5 // tokens per second
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func normalizedSenderKey(for message: BitchatMessage) -> String {
|
||||||
|
if let spid = message.senderPeerID {
|
||||||
|
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||||
|
let bare: String = {
|
||||||
|
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
|
||||||
|
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
|
||||||
|
return spid
|
||||||
|
}()
|
||||||
|
let full = (nostrKeyMapping[spid] ?? bare).lowercased()
|
||||||
|
return "nostr:" + full
|
||||||
|
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
||||||
|
return "noise:" + full
|
||||||
|
} else {
|
||||||
|
return "mesh:" + spid.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "name:" + message.sender.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedContentKey(_ content: String) -> String {
|
||||||
|
// Lowercase, simplify URLs (strip query/fragment), collapse whitespace, bound length
|
||||||
|
let lowered = content.lowercased()
|
||||||
|
let ns = lowered as NSString
|
||||||
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
|
var simplified = ""
|
||||||
|
var last = 0
|
||||||
|
for m in Regexes.simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
|
||||||
|
if m.range.location > last {
|
||||||
|
simplified += ns.substring(with: NSRange(location: last, length: m.range.location - last))
|
||||||
|
}
|
||||||
|
let url = ns.substring(with: m.range)
|
||||||
|
if let q = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
|
||||||
|
simplified += String(url[..<q])
|
||||||
|
} else {
|
||||||
|
simplified += url
|
||||||
|
}
|
||||||
|
last = m.range.location + m.range.length
|
||||||
|
}
|
||||||
|
if last < ns.length { simplified += ns.substring(with: NSRange(location: last, length: ns.length - last)) }
|
||||||
|
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
let prefix = String(collapsed.prefix(256))
|
||||||
|
// Fast djb2 hash
|
||||||
|
let h = djb2(prefix)
|
||||||
|
return String(format: "h:%016llx", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistent recent content map (LRU) to speed near-duplicate checks
|
||||||
|
private var contentLRUMap: [String: Date] = [:]
|
||||||
|
private var contentLRUOrder: [String] = []
|
||||||
|
private let contentLRUCap = 2000
|
||||||
|
private func recordContentKey(_ key: String, timestamp: Date) {
|
||||||
|
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
|
||||||
|
contentLRUMap[key] = timestamp
|
||||||
|
if contentLRUOrder.count > contentLRUCap {
|
||||||
|
let overflow = contentLRUOrder.count - contentLRUCap
|
||||||
|
for _ in 0..<overflow {
|
||||||
|
if let victim = contentLRUOrder.first {
|
||||||
|
contentLRUOrder.removeFirst()
|
||||||
|
contentLRUMap.removeValue(forKey: victim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@Published var messages: [BitchatMessage] = []
|
@Published var messages: [BitchatMessage] = []
|
||||||
|
@Published var currentColorScheme: ColorScheme = .light
|
||||||
private let maxMessages = 1337 // Maximum messages before oldest are removed
|
private let maxMessages = 1337 // Maximum messages before oldest are removed
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
private var hasNotifiedNetworkAvailable = false
|
private var hasNotifiedNetworkAvailable = false
|
||||||
@@ -287,6 +412,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Delivery tracking
|
// Delivery tracking
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Public message batching (UI perf)
|
||||||
|
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
|
||||||
|
private var publicBuffer: [BitchatMessage] = []
|
||||||
|
private var publicBufferTimer: Timer? = nil
|
||||||
|
private let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
||||||
|
private var dynamicPublicFlushInterval: TimeInterval = 0.08
|
||||||
|
private var recentBatchSizes: [Int] = []
|
||||||
|
@Published private(set) var isBatchingPublic: Bool = false
|
||||||
|
private let lateInsertThreshold: TimeInterval = 15.0
|
||||||
|
|
||||||
// Track sent read receipts to avoid duplicates (persisted across launches)
|
// Track sent read receipts to avoid duplicates (persisted across launches)
|
||||||
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
||||||
private var sentReadReceipts: Set<String> = [] { // messageID set
|
private var sentReadReceipts: Set<String> = [] { // messageID set
|
||||||
@@ -410,10 +545,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] peers in
|
.sink { [weak self] peers in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// Update peers directly
|
// Update peers directly; @Published drives UI updates
|
||||||
self.allPeers = peers
|
self.allPeers = peers
|
||||||
// Force UI update
|
|
||||||
self.objectWillChange.send()
|
|
||||||
// Update peer index for O(1) lookups
|
// Update peer index for O(1) lookups
|
||||||
// Deduplicate peers by ID to prevent crash from duplicate keys
|
// Deduplicate peers by ID to prevent crash from duplicate keys
|
||||||
var uniquePeers: [String: BitchatPeer] = [:]
|
var uniquePeers: [String: BitchatPeer] = [:]
|
||||||
@@ -578,14 +711,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Unsubscribe + resubscribe
|
// Unsubscribe + resubscribe
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
if self.processedNostrEvents.contains(event.id) { return }
|
if self.processedNostrEvents.contains(event.id) { return }
|
||||||
self.processedNostrEvents.insert(event.id)
|
self.processedNostrEvents.insert(event.id)
|
||||||
if let gh = self.currentGeohash,
|
if let gh = self.currentGeohash,
|
||||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||||
|
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
||||||
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
if Date().timeIntervalSince(eventTime) < 15 { return }
|
||||||
|
}
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||||
let nick = nickTag[1]
|
let nick = nickTag[1]
|
||||||
self.geoNicknames[event.pubkey.lowercased()] = nick
|
self.geoNicknames[event.pubkey.lowercased()] = nick
|
||||||
@@ -699,16 +837,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
self.sentReadReceipts.insert(messageId)
|
self.sentReadReceipts.insert(messageId)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Optionally notify if app backgrounded and message is truly unread
|
// Notify for truly unread and recent messages when not viewing
|
||||||
#if os(iOS)
|
if shouldMarkUnread {
|
||||||
if shouldMarkUnread && UIApplication.shared.applicationState != .active {
|
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: senderName,
|
from: senderName,
|
||||||
message: pm.content,
|
message: pm.content,
|
||||||
peerID: convKey
|
peerID: convKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
case .delivered:
|
case .delivered:
|
||||||
@@ -1095,6 +1231,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Add to main messages immediately for user feedback
|
// Add to main messages immediately for user feedback
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
|
// Update content LRU for near-dup detection
|
||||||
|
let ckey = normalizedContentKey(message.content)
|
||||||
|
recordContentKey(ckey, timestamp: message.timestamp)
|
||||||
// Persist to channel-specific timelines
|
// Persist to channel-specific timelines
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
@@ -1104,10 +1243,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
var arr = geoTimelines[ch.geohash] ?? []
|
var arr = geoTimelines[ch.geohash] ?? []
|
||||||
arr.append(message)
|
arr.append(message)
|
||||||
if arr.count > geoTimelineCap {
|
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||||
let remove = arr.count - geoTimelineCap
|
|
||||||
arr.removeFirst(remove)
|
|
||||||
}
|
|
||||||
geoTimelines[ch.geohash] = arr
|
geoTimelines[ch.geohash] = arr
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
@@ -1142,7 +1278,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
teleported: LocationChannelManager.shared.teleported
|
teleported: LocationChannelManager.shared.teleported
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
// Track ourselves as active participant
|
// Track ourselves as active participant
|
||||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
||||||
@@ -1173,6 +1314,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@MainActor
|
@MainActor
|
||||||
private func switchLocationChannel(to channel: ChannelID) {
|
private func switchLocationChannel(to channel: ChannelID) {
|
||||||
|
// Flush pending public buffer to avoid cross-channel bleed
|
||||||
|
publicBufferTimer?.invalidate(); publicBufferTimer = nil
|
||||||
|
publicBuffer.removeAll(keepingCapacity: false)
|
||||||
activeChannel = channel
|
activeChannel = channel
|
||||||
// Reset deduplication set and optionally hydrate timeline for mesh
|
// Reset deduplication set and optionally hydrate timeline for mesh
|
||||||
processedNostrEvents.removeAll()
|
processedNostrEvents.removeAll()
|
||||||
@@ -1223,7 +1367,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
geoSubscriptionID = subID
|
geoSubscriptionID = subID
|
||||||
startGeoParticipantsTimer()
|
startGeoParticipantsTimer()
|
||||||
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200)
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// Only handle ephemeral kind 20000 with matching tag
|
// Only handle ephemeral kind 20000 with matching tag
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
@@ -1246,11 +1391,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Skip our own events (we already locally echoed)
|
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||||
if let gh = self.currentGeohash,
|
if let gh = self.currentGeohash,
|
||||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||||
return
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
if Date().timeIntervalSince(eventTime) < 15 { return }
|
||||||
}
|
}
|
||||||
// Cache nickname from tag if present
|
// Cache nickname from tag if present
|
||||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||||
@@ -1378,19 +1524,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
} else {
|
} else {
|
||||||
// pared back: omit defer READ log
|
// pared back: omit defer READ log
|
||||||
}
|
}
|
||||||
// Notify only when app is backgrounded and not viewing, and only if not already read
|
// Notify for truly unread and recent messages when not viewing
|
||||||
#if os(iOS)
|
|
||||||
if !isViewing && shouldMarkUnread {
|
if !isViewing && shouldMarkUnread {
|
||||||
if UIApplication.shared.applicationState != .active {
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
from: senderName,
|
||||||
from: senderName,
|
message: pm.content,
|
||||||
message: pm.content,
|
peerID: convKey
|
||||||
peerID: convKey
|
)
|
||||||
)
|
|
||||||
// pared back: omit notification log
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
default:
|
default:
|
||||||
// Handle delivered/read receipts for our sent messages
|
// Handle delivered/read receipts for our sent messages
|
||||||
@@ -1594,7 +1735,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let subID = "geo-sample-\(gh)"
|
let subID = "geo-sample-\(gh)"
|
||||||
geoSamplingSubs[subID] = gh
|
geoSamplingSubs[subID] = gh
|
||||||
let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100)
|
let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100)
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
// Update participants for this specific geohash
|
// Update participants for this specific geohash
|
||||||
@@ -2342,7 +2484,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
teleported: LocationChannelManager.shared.teleported
|
teleported: LocationChannelManager.shared.teleported
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
// Track ourselves as active participant
|
// Track ourselves as active participant
|
||||||
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2873,11 +3025,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// For extremely long content, render as plain text to avoid heavy regex/layout work,
|
// For extremely long content, render as plain text to avoid heavy regex/layout work,
|
||||||
// unless the content includes Cashu tokens we want to chip-render below
|
// unless the content includes Cashu tokens we want to chip-render below
|
||||||
let containsCashuEarly: Bool = {
|
let containsCashuEarly: Bool = {
|
||||||
let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b"
|
let rx = Regexes.quickCashuPresence
|
||||||
if let rx = try? NSRegularExpression(pattern: pattern, options: []) {
|
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
|
||||||
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}()
|
}()
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||||
var plainStyle = AttributeContainer()
|
var plainStyle = AttributeContainer()
|
||||||
@@ -2887,33 +3036,29 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
: .system(size: 14, design: .monospaced)
|
: .system(size: 14, design: .monospaced)
|
||||||
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||||
} else {
|
} else {
|
||||||
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
// Reuse compiled regexes and detector
|
||||||
// Allow optional '#abcd' suffix in mentions
|
let hashtagRegex = Regexes.hashtag
|
||||||
let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
|
let mentionRegex = Regexes.mention
|
||||||
// Cashu token detector: cashuA/cashuB + long base64url; allow '.' and shorter variants
|
let cashuRegex = Regexes.cashu
|
||||||
let cashuPattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b"
|
let bolt11Regex = Regexes.bolt11
|
||||||
// Lightning invoices and links
|
let lnurlRegex = Regexes.lnurl
|
||||||
let bolt11Pattern = "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b"
|
let lightningSchemeRegex = Regexes.lightningScheme
|
||||||
let lnurlPattern = "(?i)\\blnurl1[a-z0-9]{20,}\\b"
|
let detector = Regexes.linkDetector
|
||||||
let lightningSchemePattern = "(?i)\\blightning:[^\\s]+"
|
|
||||||
|
|
||||||
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
let nsLen = content.count
|
||||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
let hasMentionsHint = content.contains("@")
|
||||||
let cashuRegex = try? NSRegularExpression(pattern: cashuPattern, options: [])
|
let hasHashtagsHint = content.contains("#")
|
||||||
let bolt11Regex = try? NSRegularExpression(pattern: bolt11Pattern, options: [])
|
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||||
let lnurlRegex = try? NSRegularExpression(pattern: lnurlPattern, options: [])
|
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||||||
let lightningSchemeRegex = try? NSRegularExpression(pattern: lightningSchemePattern, options: [])
|
let hasCashuHint = content.lowercased().contains("cashu")
|
||||||
|
|
||||||
// Use NSDataDetector for URL detection
|
let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
|
||||||
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
let urlMatches = detector?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
let cashuMatches = cashuRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
let lightningMatches = lightningSchemeRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
|
||||||
let bolt11Matches = bolt11Regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
|
||||||
let lnurlMatches = lnurlRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
|
||||||
|
|
||||||
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
||||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||||
@@ -3343,8 +3488,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
private func trimMessagesIfNeeded() {
|
private func trimMessagesIfNeeded() {
|
||||||
if messages.count > maxMessages {
|
if messages.count > maxMessages {
|
||||||
let removeCount = messages.count - maxMessages
|
messages = Array(messages.suffix(maxMessages))
|
||||||
messages.removeFirst(removeCount)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3413,8 +3557,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
private func trimMeshTimelineIfNeeded() {
|
private func trimMeshTimelineIfNeeded() {
|
||||||
if meshTimeline.count > meshTimelineCap {
|
if meshTimeline.count > meshTimelineCap {
|
||||||
let removeCount = meshTimeline.count - meshTimelineCap
|
meshTimeline = Array(meshTimeline.suffix(meshTimelineCap))
|
||||||
meshTimeline.removeFirst(removeCount)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3445,9 +3588,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private func addMessage(_ message: BitchatMessage) {
|
private func addMessage(_ message: BitchatMessage) {
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
guard !messages.contains(where: { $0.id == message.id }) else { return }
|
guard !messages.contains(where: { $0.id == message.id }) else { return }
|
||||||
|
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
messages.sort { $0.timestamp < $1.timestamp }
|
|
||||||
trimMessagesIfNeeded()
|
trimMessagesIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4151,7 +4292,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
teleported: LocationChannelManager.shared.teleported
|
teleported: LocationChannelManager.shared.teleported
|
||||||
)
|
)
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
|
SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error)
|
||||||
}
|
}
|
||||||
@@ -4951,8 +5097,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
privateChats = chats
|
privateChats = chats
|
||||||
trimPrivateChatMessagesIfNeeded(for: peerID)
|
trimPrivateChatMessagesIfNeeded(for: peerID)
|
||||||
|
|
||||||
// Trigger UI update
|
// UI updates via @Published reassignment above
|
||||||
objectWillChange.send()
|
|
||||||
|
|
||||||
// Handle fingerprint-based chat updates
|
// Handle fingerprint-based chat updates
|
||||||
if let chatFingerprint = selectedPrivateChatFingerprint,
|
if let chatFingerprint = selectedPrivateChatFingerprint,
|
||||||
@@ -5025,6 +5170,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system)
|
// Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system)
|
||||||
let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true
|
let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true
|
||||||
|
|
||||||
|
// Apply per-sender and per-content rate limits (drop if exceeded)
|
||||||
|
if finalMessage.sender != "system" {
|
||||||
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||||
|
let contentKey = normalizedContentKey(finalMessage.content)
|
||||||
|
let now = Date()
|
||||||
|
var sBucket = rateBucketsBySender[senderKey] ?? TokenBucket(capacity: senderBucketCapacity, tokens: senderBucketCapacity, refillPerSec: senderBucketRefill, lastRefill: now)
|
||||||
|
let senderAllowed = sBucket.allow(now: now)
|
||||||
|
rateBucketsBySender[senderKey] = sBucket
|
||||||
|
var cBucket = rateBucketsByContent[contentKey] ?? TokenBucket(capacity: contentBucketCapacity, tokens: contentBucketCapacity, refillPerSec: contentBucketRefill, lastRefill: now)
|
||||||
|
let contentAllowed = cBucket.allow(now: now)
|
||||||
|
rateBucketsByContent[contentKey] = cBucket
|
||||||
|
if !(senderAllowed && contentAllowed) { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size cap: drop extremely large public messages early
|
||||||
|
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
|
||||||
|
|
||||||
// Persist mesh messages to mesh timeline always
|
// Persist mesh messages to mesh timeline always
|
||||||
if !isGeo && finalMessage.sender != "system" {
|
if !isGeo && finalMessage.sender != "system" {
|
||||||
meshTimeline.append(finalMessage)
|
meshTimeline.append(finalMessage)
|
||||||
@@ -5037,10 +5199,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if let gh = currentGeohash {
|
if let gh = currentGeohash {
|
||||||
var arr = geoTimelines[gh] ?? []
|
var arr = geoTimelines[gh] ?? []
|
||||||
arr.append(finalMessage)
|
arr.append(finalMessage)
|
||||||
if arr.count > geoTimelineCap {
|
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
|
||||||
let remove = arr.count - geoTimelineCap
|
|
||||||
arr.removeFirst(remove)
|
|
||||||
}
|
|
||||||
geoTimelines[gh] = arr
|
geoTimelines[gh] = arr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5086,32 +5245,103 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Check if this is our own message being echoed back (avoid dup)
|
// Append via batching buffer (skip empty content)
|
||||||
if finalMessage.sender != nickname && finalMessage.sender != "system" {
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
enqueuePublic(finalMessage)
|
||||||
addMessage(finalMessage)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if finalMessage.sender != "system" {
|
// MARK: - Public message batching helpers
|
||||||
// Check for duplicates
|
@MainActor
|
||||||
let messageExists = messages.contains { existingMsg in
|
private func enqueuePublic(_ message: BitchatMessage) {
|
||||||
if existingMsg.id == finalMessage.id { return true }
|
publicBuffer.append(message)
|
||||||
if existingMsg.content == finalMessage.content && existingMsg.sender == finalMessage.sender {
|
schedulePublicFlush()
|
||||||
let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
|
}
|
||||||
return timeDiff < 1.0
|
|
||||||
}
|
@MainActor
|
||||||
return false
|
private func schedulePublicFlush() {
|
||||||
|
if publicBufferTimer != nil { return }
|
||||||
|
publicBufferTimer = Timer.scheduledTimer(timeInterval: dynamicPublicFlushInterval,
|
||||||
|
target: self,
|
||||||
|
selector: #selector(onPublicBufferTimerFired(_:)),
|
||||||
|
userInfo: nil,
|
||||||
|
repeats: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func flushPublicBuffer() {
|
||||||
|
publicBufferTimer?.invalidate()
|
||||||
|
publicBufferTimer = nil
|
||||||
|
guard !publicBuffer.isEmpty else { return }
|
||||||
|
|
||||||
|
// Dedup against existing by id and near-duplicate messages by content (within ~1s), across senders
|
||||||
|
var seenIDs = Set(messages.map { $0.id })
|
||||||
|
var added: [BitchatMessage] = []
|
||||||
|
var batchContentLatest: [String: Date] = [:]
|
||||||
|
for m in publicBuffer {
|
||||||
|
if seenIDs.contains(m.id) { continue }
|
||||||
|
let ckey = normalizedContentKey(m.content)
|
||||||
|
if let ts = contentLRUMap[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue }
|
||||||
|
if let ts = batchContentLatest[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue }
|
||||||
|
seenIDs.insert(m.id)
|
||||||
|
added.append(m)
|
||||||
|
batchContentLatest[ckey] = m.timestamp
|
||||||
|
}
|
||||||
|
publicBuffer.removeAll(keepingCapacity: true)
|
||||||
|
guard !added.isEmpty else { return }
|
||||||
|
|
||||||
|
// Indicate batching for conditional UI animations
|
||||||
|
isBatchingPublic = true
|
||||||
|
// Rough chronological order: sort the batch by timestamp before inserting
|
||||||
|
added.sort { $0.timestamp < $1.timestamp }
|
||||||
|
// Insert late arrivals into approximate position; append recent ones
|
||||||
|
let lastTs = messages.last?.timestamp ?? .distantPast
|
||||||
|
for m in added {
|
||||||
|
if m.timestamp < lastTs.addingTimeInterval(-lateInsertThreshold) {
|
||||||
|
let idx = insertionIndexByTimestamp(m.timestamp)
|
||||||
|
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
|
||||||
|
} else {
|
||||||
|
messages.append(m)
|
||||||
}
|
}
|
||||||
if !messageExists && !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
// Record content key for LRU
|
||||||
addMessage(finalMessage)
|
let ckey = normalizedContentKey(m.content)
|
||||||
}
|
recordContentKey(ckey, timestamp: m.timestamp)
|
||||||
} else {
|
}
|
||||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
trimMessagesIfNeeded()
|
||||||
addMessage(finalMessage)
|
// Update batch size stats and adjust interval
|
||||||
|
recentBatchSizes.append(added.count)
|
||||||
|
if recentBatchSizes.count > 10 { recentBatchSizes.removeFirst(recentBatchSizes.count - 10) }
|
||||||
|
let avg = recentBatchSizes.isEmpty ? 0.0 : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
|
||||||
|
dynamicPublicFlushInterval = avg > 100.0 ? 0.12 : basePublicFlushInterval
|
||||||
|
// Prewarm formatting cache for current UI color scheme only
|
||||||
|
for m in added {
|
||||||
|
_ = self.formatMessageAsText(m, colorScheme: currentColorScheme)
|
||||||
|
}
|
||||||
|
// Reset batching flag (already on main actor)
|
||||||
|
isBatchingPublic = false
|
||||||
|
// If new items arrived during this flush, coalesce by flushing once more next tick
|
||||||
|
if !publicBuffer.isEmpty { schedulePublicFlush() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer selector to avoid @Sendable closure capture issues under Swift 6
|
||||||
|
@MainActor @objc
|
||||||
|
private func onPublicBufferTimerFired(_ timer: Timer) {
|
||||||
|
flushPublicBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func insertionIndexByTimestamp(_ ts: Date) -> Int {
|
||||||
|
var low = 0
|
||||||
|
var high = messages.count
|
||||||
|
while low < high {
|
||||||
|
let mid = (low + high) / 2
|
||||||
|
if messages[mid].timestamp < ts {
|
||||||
|
low = mid + 1
|
||||||
|
} else {
|
||||||
|
high = mid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return low
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check for mentions and send notifications
|
/// Check for mentions and send notifications
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ struct ContentView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
// Base layer - Main public chat (always visible)
|
// Base layer - Main public chat (always visible)
|
||||||
mainChatView
|
mainChatView
|
||||||
|
.onAppear { viewModel.currentColorScheme = colorScheme }
|
||||||
|
.onChange(of: colorScheme) { newValue in
|
||||||
|
viewModel.currentColorScheme = newValue
|
||||||
|
}
|
||||||
|
|
||||||
// Private chat slide-over
|
// Private chat slide-over
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
if viewModel.selectedPrivateChatPeer != nil {
|
||||||
@@ -310,9 +314,11 @@ struct ContentView: View {
|
|||||||
} else {
|
} else {
|
||||||
// Regular messages with natural text wrapping
|
// Regular messages with natural text wrapping
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Precompute heavy token scans once per row
|
||||||
|
let cashuTokens = message.content.extractCashuTokens()
|
||||||
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
// Single text view for natural wrapping
|
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
|
||||||
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty
|
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -328,9 +334,9 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty {
|
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Button(isExpanded ? "Show less" : "Show more") {
|
Button(isExpanded ? "show less" : "show more") {
|
||||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
else { expandedMessageIDs.insert(message.id) }
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
}
|
}
|
||||||
@@ -339,11 +345,7 @@ struct ContentView: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link previews removed: URLs appear inline and are tappable
|
|
||||||
|
|
||||||
// Render payment chips (Lightning / Cashu) with rounded background
|
// Render payment chips (Lightning / Cashu) with rounded background
|
||||||
let lightningLinks = message.content.extractLightningLinks()
|
|
||||||
let cashuTokens = message.content.extractCashuTokens()
|
|
||||||
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in
|
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in
|
||||||
@@ -453,6 +455,7 @@ struct ContentView: View {
|
|||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
|
|||||||
@@ -5,6 +5,21 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
private enum RegexCache {
|
||||||
|
static let cashu: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lightningScheme: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||||
|
}()
|
||||||
|
static let bolt11: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lnurl: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
|
||||||
func hasVeryLongToken(threshold: Int) -> Bool {
|
func hasVeryLongToken(threshold: Int) -> Bool {
|
||||||
@@ -23,8 +38,7 @@ extension String {
|
|||||||
|
|
||||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||||
func extractCashuTokens(max: Int = 3) -> [String] {
|
func extractCashuTokens(max: Int = 3) -> [String] {
|
||||||
let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b"
|
let regex = RegexCache.cashu
|
||||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
|
|
||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
var found: [String] = []
|
var found: [String] = []
|
||||||
@@ -44,28 +58,22 @@ extension String {
|
|||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let full = NSRange(location: 0, length: ns.length)
|
let full = NSRange(location: 0, length: ns.length)
|
||||||
// lightning: scheme
|
// lightning: scheme
|
||||||
if let schemeRx = try? NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) {
|
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
|
||||||
for m in schemeRx.matches(in: self, options: [], range: full) {
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
results.append(s)
|
||||||
results.append(s)
|
if results.count >= max { return results }
|
||||||
if results.count >= max { return results }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// BOLT11
|
// BOLT11
|
||||||
if let boltRx = try? NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) {
|
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
|
||||||
for m in boltRx.matches(in: self, options: [], range: full) {
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
results.append("lightning:\(s)")
|
||||||
results.append("lightning:\(s)")
|
if results.count >= max { return results }
|
||||||
if results.count >= max { return results }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// LNURL bech32
|
// LNURL bech32
|
||||||
if let lnurlRx = try? NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) {
|
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
|
||||||
for m in lnurlRx.matches(in: self, options: [], range: full) {
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
results.append("lightning:\(s)")
|
||||||
results.append("lightning:\(s)")
|
if results.count >= max { return results }
|
||||||
if results.count >= max { return results }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
Relay URL,Latitude,Longitude
|
||||||
|
relay.damus.io,37.7621,-122.3971
|
||||||
|
nostr-pub.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.mom,50.4779,12.3713
|
||||||
|
nostr.slothy.win,37.7621,-122.3971
|
||||||
|
nostr.einundzwanzig.space,50.1155,8.6842
|
||||||
|
nos.lol,50.4779,12.3713
|
||||||
|
relay.nostr.band,60.1695,24.9354
|
||||||
|
no.str.cr,9.9339,-84.0849
|
||||||
|
nostr.massmux.com,50.1155,8.6842
|
||||||
|
nostr-relay.schnitzel.world,39.0437,-77.4875
|
||||||
|
relay.nostr.com.au,37.7621,-122.3971
|
||||||
|
knostr.neutrine.com,48.8534,2.3488
|
||||||
|
nostr.nodeofsven.com,47.4875,8.2965
|
||||||
|
nostr.vulpem.com,49.4542,11.0775
|
||||||
|
nostr-verif.slothy.win,37.7621,-122.3971
|
||||||
|
relay.lexingtonbitcoin.org,37.7621,-122.3971
|
||||||
|
nostr-1.nbo.angani.co,-1.2615,36.7903
|
||||||
|
relay.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.easydns.ca,43.7064,-79.3986
|
||||||
|
relay.dwadziesciajeden.pl,52.2298,21.0118
|
||||||
|
nostr.data.haus,50.4779,12.3713
|
||||||
|
nostr.einundzwanzig.space,50.1155,8.6842
|
||||||
|
nostr.mom,50.4779,12.3713
|
||||||
|
nos.lol,50.4779,12.3713
|
||||||
|
relay.nostr.band,60.1695,24.9354
|
||||||
|
nostr.massmux.com,50.1155,8.6842
|
||||||
|
nostr.vulpem.com,49.4542,11.0775
|
||||||
|
relay.damus.io,37.7621,-122.3971
|
||||||
|
nostr-pub.wellorder.net,45.5229,-122.9898
|
||||||
|
no.str.cr,9.9339,-84.0849
|
||||||
|
relay.dwadziesciajeden.pl,52.2298,21.0118
|
||||||
|
nostr.data.haus,50.4779,12.3713
|
||||||
|
relay.wellorder.net,45.5229,-122.9898
|
||||||
|
relay.nostromo.social,49.4542,11.0775
|
||||||
|
offchain.pub,34.0522,-118.2437
|
||||||
|
relay.nostr.wirednet.jp,35.9356,139.3044
|
||||||
|
relay.nostrcheck.me,37.7621,-122.3971
|
||||||
|
nostrue.com,40.8043,-74.0121
|
||||||
|
nostr-relay.schnitzel.world,39.0437,-77.4875
|
||||||
|
nproxy.kristapsk.lv,60.1695,24.9354
|
||||||
|
nostr.spaceshell.xyz,37.7621,-122.3971
|
||||||
|
nostr-dev.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr-verified.wellorder.net,45.5229,-122.9898
|
||||||
|
nostr.roundrockbitcoiners.com,40.8043,-74.0121
|
||||||
|
slick.mjex.me,39.0437,-77.4875
|
||||||
|
nostr.yael.at,52.3740,4.8897
|
||||||
|
relay.primal.net,37.7621,-122.3971
|
||||||
|
nostr.oxtr.dev,50.4779,12.3713
|
||||||
|
nostr.21crypto.ch,46.5160,6.6328
|
||||||
|
nostr.liberty.fans,38.8003,-90.6265
|
||||||
|
nostr-02.dorafactory.org,1.2897,103.8501
|
||||||
|
relay.hodl.ar,-32.9468,-60.6393
|
||||||
|
nostr.middling.mydns.jp,35.8089,140.1185
|
||||||
|
nostr.namek.link,37.7621,-122.3971
|
||||||
|
nostrja-kari.heguro.com,37.7621,-122.3971
|
||||||
|
nostr.hifish.org,47.3667,8.5500
|
||||||
|
nostr.rikmeijer.nl,50.4779,12.3713
|
||||||
|
black.nostrcity.club,41.8119,-87.6873
|
||||||
|
nostr.hekster.org,37.3924,-121.9623
|
||||||
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
|
nostr.sagaciousd.com,49.2497,-123.1193
|
||||||
|
nostr.fbxl.net,43.7064,-79.3986
|
||||||
|
ithurtswhenip.ee,50.7990,-1.0913
|
||||||
|
relay2.nostrchat.io,49.4542,11.0775
|
||||||
|
relay1.nostrchat.io,60.1695,24.9354
|
||||||
|
nostr-01.yakihonne.com,1.3215,103.6957
|
||||||
|
nostr.sathoarder.com,48.5839,7.7455
|
||||||
|
nostr.overmind.lol,37.7621,-122.3971
|
||||||
|
relay.verified-nostr.com,37.7621,-122.3971
|
||||||
|
purplerelay.com,50.1155,8.6842
|
||||||
|
relay.orangepill.ovh,49.0127,1.9694
|
||||||
|
nostr-relay.psfoundation.info,39.0437,-77.4875
|
||||||
|
soloco.nl,37.7621,-122.3971
|
||||||
|
relay.froth.zone,60.1695,24.9354
|
||||||
|
nostr.stakey.net,52.5250,5.7181
|
||||||
|
nostr.2b9t.xyz,34.0522,-118.2437
|
||||||
|
pyramid.fiatjaf.com,50.1155,8.6842
|
||||||
|
a.nos.lol,50.4779,12.3713
|
||||||
|
relay.magiccity.live,25.8130,-80.2320
|
||||||
|
nostr.notribe.net,40.8344,-74.1377
|
||||||
|
freelay.sovbit.host,64.1355,-21.8954
|
||||||
|
relay.credenso.cafe,43.4254,-80.5112
|
||||||
|
nostr.huszonegy.world,47.4984,19.0404
|
||||||
|
multiplexer.huszonegy.world,47.4984,19.0404
|
||||||
|
bucket.coracle.social,37.7621,-122.3971
|
||||||
|
nostr.kungfu-g.rip,33.7865,-84.4454
|
||||||
|
relay.artx.market,43.7064,-79.3986
|
||||||
|
relay.notoshi.win,13.3622,100.9835
|
||||||
|
vitor.nostr1.com,40.7143,-74.0060
|
||||||
|
nostr-02.yakihonne.com,1.3215,103.6957
|
||||||
|
nostr-03.dorafactory.org,1.2897,103.8501
|
||||||
|
n.ok0.org,-36.8485,174.7635
|
||||||
|
nostr.0x7e.xyz,47.5056,8.7241
|
||||||
|
relay.nostr.net,50.4779,12.3713
|
||||||
|
strfry.openhoofd.nl,51.5717,3.7042
|
||||||
|
relay.fountain.fm,39.0997,-94.5786
|
||||||
|
relay.usefusion.ai,38.7135,-78.1594
|
||||||
|
relay.varke.eu,52.6958,6.1944
|
||||||
|
nostr.satstralia.com,64.1355,-21.8954
|
||||||
|
relay.13room.space,37.7621,-122.3971
|
||||||
|
nostr.myshosholoza.co.za,52.3710,4.9042
|
||||||
|
nostr.carroarmato0.be,50.8517,3.6089
|
||||||
|
nostr.dbtc.link,37.7621,-122.3971
|
||||||
|
orangepiller.org,60.1695,24.9354
|
||||||
|
adre.su,59.9386,30.3141
|
||||||
|
relay.sincensura.org,37.7621,-122.3971
|
||||||
|
relay.freeplace.nl,52.3740,4.8897
|
||||||
|
bostr.bitcointxoko.com,64.1355,-21.8954
|
||||||
|
nostr.plantroon.com,50.1025,8.6299
|
||||||
|
srtrelay.c-stellar.net,37.7621,-122.3971
|
||||||
|
nostr.jfischer.org,49.4453,11.0222
|
||||||
|
nostr.novacisko.cz,52.2298,21.0118
|
||||||
|
relay.lumina.rocks,49.4453,11.0222
|
||||||
|
nostr.tavux.tech,50.9519,1.8563
|
||||||
|
relay.nostrhub.fr,50.1155,8.6842
|
||||||
|
relay.agorist.space,52.3740,4.8897
|
||||||
|
chorus.pjv.me,45.5229,-122.9898
|
||||||
|
relay.cosmicbolt.net,37.3924,-121.9623
|
||||||
|
santo.iguanatech.net,40.8344,-74.1377
|
||||||
|
relay.tagayasu.xyz,45.4112,-75.6981
|
||||||
|
relay.mostro.network,40.8344,-74.1377
|
||||||
|
relay.zone667.com,60.1695,24.9354
|
||||||
|
relay5.bitransfer.org,37.7621,-122.3971
|
||||||
|
relay.illuminodes.com,47.6062,-122.3321
|
||||||
|
relay2.angor.io,50.1155,8.6842
|
||||||
|
relay.satsdays.com,1.2897,103.8501
|
||||||
|
relay.angor.io,50.1155,8.6842
|
||||||
|
orangesync.tech,50.9333,6.9500
|
||||||
|
nostr-relay.cbrx.io,37.7621,-122.3971
|
||||||
|
relay.21e6.cz,50.0880,14.4208
|
||||||
|
nostr.chaima.info,50.1155,8.6842
|
||||||
|
relay.satlantis.io,32.8546,-79.9748
|
||||||
|
relay.digitalezukunft.cyou,45.5088,-73.5878
|
||||||
|
relay.tapestry.ninja,40.8043,-74.0121
|
||||||
|
relay.minibolt.info,37.7621,-122.3971
|
||||||
|
nostr.bilthon.dev,25.8130,-80.2320
|
||||||
|
nostr.makibisskey.work,37.7621,-122.3971
|
||||||
|
relay.mattybs.lol,37.7621,-122.3971
|
||||||
|
noxir.kpherox.dev,34.8436,135.5084
|
||||||
|
sendit.nosflare.com,37.7621,-122.3971
|
||||||
|
relay.coinos.io,37.7621,-122.3971
|
||||||
|
relay.nostraddress.com,37.7621,-122.3971
|
||||||
|
wot.nostr.party,36.1659,-86.7844
|
||||||
|
nostrelites.org,41.8500,-87.6500
|
||||||
|
relay.nostriot.com,43.7064,-79.3986
|
||||||
|
prl.plus,55.7522,37.6156
|
||||||
|
zap.watch,45.5088,-73.5878
|
||||||
|
wot.codingarena.top,50.4779,12.3713
|
||||||
|
nostr.azzamo.net,52.2284,21.0522
|
||||||
|
wot.sudocarlos.com,43.7064,-79.3986
|
||||||
|
relay.lnfi.network,45.6241,8.7851
|
||||||
|
wot.nostr.net,37.7621,-122.3971
|
||||||
|
relay.nostrdice.com,-33.8678,151.2073
|
||||||
|
wot.sebastix.social,51.3700,6.1681
|
||||||
|
wheat.happytavern.co,37.7621,-122.3971
|
||||||
|
relay.sigit.io,50.4779,12.3713
|
||||||
|
strfry.bonsai.com,37.8716,-122.2728
|
||||||
|
travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873
|
||||||
|
satsage.xyz,37.3924,-121.9623
|
||||||
|
relay.degmods.com,50.4779,12.3713
|
||||||
|
nostr.community.ath.cx,45.5088,-73.5878
|
||||||
|
nostr.coincrowd.fund,39.0437,-77.4875
|
||||||
|
strfry.shock.network,41.8847,-88.2040
|
||||||
|
cyberspace.nostr1.com,40.7143,-74.0060
|
||||||
|
relay02.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875
|
||||||
|
relay.davidebtc.me,50.1155,8.6842
|
||||||
|
wot.dtonon.com,37.7621,-122.3971
|
||||||
|
relay.goodmorningbitcoin.com,37.7621,-122.3971
|
||||||
|
articles.layer3.news,37.3394,-121.8950
|
||||||
|
bostr.syobon.net,37.7621,-122.3971
|
||||||
|
nostr.agentcampfire.com,52.3740,4.8897
|
||||||
|
nostr.thebiglake.org,32.7244,-96.6755
|
||||||
|
schnorr.me,37.7621,-122.3971
|
||||||
|
relay.wolfcoil.com,35.6090,139.7302
|
||||||
|
nostr.camalolo.com,24.1469,120.6839
|
||||||
|
nostr.tac.lol,47.4740,-122.2610
|
||||||
|
dev-relay.lnfi.network,39.0997,-94.5786
|
||||||
|
relay.bitcoinveneto.org,64.1355,-21.8954
|
||||||
|
nostr.red5d.dev,37.7621,-122.3971
|
||||||
|
relay-testnet.k8s.layer3.news,37.3394,-121.8950
|
||||||
|
promenade.fiatjaf.com,50.1155,8.6842
|
||||||
|
nostrelay.memory-art.xyz,37.7621,-122.3971
|
||||||
|
inbox.azzamo.net,52.2284,21.0522
|
||||||
|
social.proxymana.net,60.1695,24.9354
|
||||||
|
relay.netstr.io,53.3331,-6.2489
|
||||||
|
premium.primal.net,37.7621,-122.3971
|
||||||
|
nostr.lojong.info,37.7621,-122.3971
|
||||||
|
nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971
|
||||||
|
relay.stream.labs.h3.se,59.3294,18.0687
|
||||||
|
tollbooth.stens.dev,51.4566,7.0123
|
||||||
|
relay.chakany.systems,37.7621,-122.3971
|
||||||
|
relay.mwaters.net,50.9519,1.8563
|
||||||
|
nostr-relay.shirogaku.xyz,37.7621,-122.3971
|
||||||
|
kitchen.zap.cooking,37.7621,-122.3971
|
||||||
|
relay.arx-ccn.com,50.4779,12.3713
|
||||||
|
relay.fr13nd5.com,50.1155,8.6842
|
||||||
|
nostr.tegila.com.br,39.0437,-77.4875
|
||||||
|
relay.jeffg.fyi,43.7064,-79.3986
|
||||||
|
relay.bullishbounty.com,37.7621,-122.3971
|
||||||
|
nostr.spicyz.io,37.7621,-122.3971
|
||||||
|
relay04.lnfi.network,39.0997,-94.5786
|
||||||
|
vidono.apps.slidestr.net,48.8534,2.3488
|
||||||
|
relay03.lnfi.network,39.0997,-94.5786
|
||||||
|
communities.nos.social,40.8344,-74.1377
|
||||||
|
relay.evanverma.com,40.8344,-74.1377
|
||||||
|
nostrelay.circum.space,51.4566,7.0123
|
||||||
|
wot.brightbolt.net,47.6928,-116.7850
|
||||||
|
relayrs.notoshi.win,37.7621,-122.3971
|
||||||
|
fenrir-s.notoshi.win,37.7621,-122.3971
|
||||||
|
relay.nsnip.io,60.1695,24.9354
|
||||||
|
x.kojira.io,37.7621,-122.3971
|
||||||
|
relay.hasenpfeffr.com,39.0437,-77.4875
|
||||||
|
relay01.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr.rtvslawenia.com,49.4542,11.0775
|
||||||
|
relay.g1sms.fr,43.9298,2.1480
|
||||||
|
nostr.kalf.org,52.3740,4.8897
|
||||||
|
nostr.rblb.it,37.7621,-122.3971
|
||||||
|
nostr.4rs.nl,49.4453,11.0222
|
||||||
|
relay.vrtmrz.net,37.7621,-122.3971
|
||||||
|
nostr.hoppe-relay.it.com,45.5946,-121.1787
|
||||||
|
relay-rpi.edufeed.org,49.4453,11.0222
|
||||||
|
relay.copylaradio.com,50.7990,-1.0913
|
||||||
|
relay.ru.ac.th,13.7540,100.5014
|
||||||
|
relay.bitcoinartclock.com,50.4779,12.3713
|
||||||
|
wot.downisontheup.ca,47.6062,-122.3321
|
||||||
|
nostr.coincards.com,43.7064,-79.3986
|
||||||
|
relay.etch.social,41.2619,-95.8608
|
||||||
|
relay.mess.ch,47.1345,9.0964
|
||||||
|
relay.holzeis.me,37.7621,-122.3971
|
||||||
|
relay-admin.thaliyal.com,40.8220,-74.4488
|
||||||
|
nostr.thaliyal.com,40.8220,-74.4488
|
||||||
|
strfry.felixzieger.de,50.1025,8.6299
|
||||||
|
nostr.smut.cloud,37.7621,-122.3971
|
||||||
|
r.bitcoinhold.net,37.7621,-122.3971
|
||||||
|
nostr.blankfors.se,60.1695,24.9354
|
||||||
|
portal-relay.pareto.space,49.4453,11.0222
|
||||||
|
relay.getsafebox.app,43.7064,-79.3986
|
||||||
|
relay.anzenkodo.workers.dev,37.7621,-122.3971
|
||||||
|
relay.nostrhub.tech,49.4453,11.0222
|
||||||
|
nostr.prl.plus,52.3740,4.8897
|
||||||
|
nostr-2.21crypto.ch,46.5160,6.6328
|
||||||
|
nostr.zenon.network,40.7143,-74.0060
|
||||||
|
nostr-relay.amethyst.name,35.7721,-78.6386
|
||||||
|
relayone.geektank.ai,17.1210,-61.8433
|
||||||
|
fanfares.nostr1.com,40.7143,-74.0060
|
||||||
|
wot.geektank.ai,17.1210,-61.8433
|
||||||
|
relay-dev.satlantis.io,40.8344,-74.1377
|
||||||
|
relay.siamdev.cc,13.9178,100.4240
|
||||||
|
relay.nosto.re,51.3700,6.1681
|
||||||
|
wot.soundhsa.com,39.0997,-94.5786
|
||||||
|
nostr.n7ekb.net,47.5707,-122.2221
|
||||||
|
relayone.soundhsa.com,39.0997,-94.5786
|
||||||
|
relay.puresignal.news,37.7621,-122.3971
|
||||||
|
relay.nostx.io,37.7621,-122.3971
|
||||||
|
nostr.now,35.6090,139.7302
|
||||||
|
relay.artiostr.ch,37.7621,-122.3971
|
||||||
|
relay.oldenburg.cool,50.1155,8.6842
|
||||||
|
theoutpost.life,64.1355,-21.8954
|
||||||
|
khatru.nostrver.se,51.3700,6.1681
|
||||||
|
relay.wavefunc.live,37.7915,-122.4018
|
||||||
|
nostr-relay.zimage.com,34.0522,-118.2437
|
||||||
|
relay.javi.space,43.4628,11.8807
|
||||||
|
bostr.shop,42.8865,-78.8784
|
||||||
|
relay.letsfo.com,52.2298,21.0118
|
||||||
|
alien.macneilmediagroup.com,37.7621,-122.3971
|
||||||
|
rn1.sotiras.org,37.7621,-122.3971
|
||||||
|
gnostr.com,42.6975,23.3241
|
||||||
|
relay.conduit.market,37.7621,-122.3971
|
||||||
|
relay.hivetalk.org,37.3924,-121.9623
|
||||||
|
nostr.l484.com,30.2960,-97.6396
|
||||||
|
relay.chorus.community,50.1155,8.6842
|
||||||
|
nostr-relay.moe.gift,37.7621,-122.3971
|
||||||
|
relay.nostrcal.com,37.7621,-122.3971
|
||||||
|
temp.iris.to,37.7621,-122.3971
|
||||||
|
librerelay.aaroniumii.com,37.7621,-122.3971
|
||||||
|
nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971
|
||||||
|
relay.barine.co,37.7621,-122.3971
|
||||||
|
nostr.rohoss.com,48.1374,11.5755
|
||||||
|
wot.nostr.place,30.2672,-97.7431
|
||||||
|
relay.utxo.farm,34.7331,135.8183
|
||||||
|
relay.bankless.at,37.7621,-122.3971
|
||||||
|
relay.toastr.net,40.8043,-74.0121
|
||||||
|
nostr.excentered.com,52.5244,13.4105
|
||||||
|
relay.mccormick.cx,52.3740,4.8897
|
||||||
|
relay.cypherflow.ai,48.8534,2.3488
|
||||||
|
relay.laantungir.net,45.3134,-73.8725
|
||||||
|
nostr.veladan.dev,37.7621,-122.3971
|
||||||
|
nostr.tadryanom.me,37.7621,-122.3971
|
||||||
|
nostr-relay.online,37.7621,-122.3971
|
||||||
|
nostr.night7.space,50.4779,12.3713
|
||||||
|
dev-nostr.bityacht.io,25.0531,121.5264
|
||||||
|
Reference in New Issue
Block a user