mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:25:19 +00:00
Nostr: add EOSE handling with callback support for subscriptions; wire to LocationNotesManager/Counter (initialLoadComplete). Remove 'building' level from channel enum and geocoder mapping; geohash list shows block/neighborhood/city/province/region only
This commit is contained in:
@@ -1112,7 +1112,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.4;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -1143,7 +1143,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.4;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1198,7 +1198,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.4;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1230,7 +1230,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.4;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1319,7 +1319,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.4;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1412,7 +1412,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.4;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var subscribeCoalesce: [String: Date] = [:]
|
private var subscribeCoalesce: [String: Date] = [:]
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// Track EOSE per subscription to signal when initial stored events are done
|
||||||
|
private struct EOSETracker {
|
||||||
|
var pendingRelays: Set<String>
|
||||||
|
var callback: () -> Void
|
||||||
|
var timer: Timer?
|
||||||
|
}
|
||||||
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability
|
||||||
// Pending sends held only for relays that are not yet connected.
|
// Pending sends held only for relays that are not yet connected.
|
||||||
private struct PendingSend {
|
private struct PendingSend {
|
||||||
@@ -201,7 +209,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
relayUrls: [String]? = nil,
|
relayUrls: [String]? = nil,
|
||||||
handler: @escaping (NostrEvent) -> Void
|
handler: @escaping (NostrEvent) -> Void,
|
||||||
|
onEOSE: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -250,6 +259,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
map[id] = messageString
|
map[id] = messageString
|
||||||
self.pendingSubscriptions[url] = map
|
self.pendingSubscriptions[url] = map
|
||||||
}
|
}
|
||||||
|
// Initialize EOSE tracking if requested
|
||||||
|
if let onEOSE = onEOSE {
|
||||||
|
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||||
|
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||||
|
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if let t = self.eoseTrackers[id] {
|
||||||
|
t.timer?.invalidate()
|
||||||
|
self.eoseTrackers.removeValue(forKey: id)
|
||||||
|
onEOSE()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eoseTrackers[id] = tracker
|
||||||
|
}
|
||||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||||
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
||||||
ensureConnections(to: urls)
|
ensureConnections(to: urls)
|
||||||
@@ -418,9 +441,17 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||||
}
|
}
|
||||||
case .eose:
|
case .eose(let subId):
|
||||||
// No-op for now
|
if var tracker = eoseTrackers[subId] {
|
||||||
break
|
tracker.pendingRelays.remove(relayUrl)
|
||||||
|
if tracker.pendingRelays.isEmpty {
|
||||||
|
tracker.timer?.invalidate()
|
||||||
|
eoseTrackers.removeValue(forKey: subId)
|
||||||
|
tracker.callback()
|
||||||
|
} else {
|
||||||
|
eoseTrackers[subId] = tracker
|
||||||
|
}
|
||||||
|
}
|
||||||
case .ok(let eventId, let success, let reason):
|
case .ok(let eventId, let success, let reason):
|
||||||
if success {
|
if success {
|
||||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Foundation
|
|||||||
|
|
||||||
/// Levels of location channels mapped to geohash precisions.
|
/// Levels of location channels mapped to geohash precisions.
|
||||||
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||||
case building
|
|
||||||
case block
|
case block
|
||||||
case neighborhood
|
case neighborhood
|
||||||
case city
|
case city
|
||||||
@@ -12,7 +11,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
/// Geohash length used for this level.
|
/// Geohash length used for this level.
|
||||||
var precision: Int {
|
var precision: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .building: return 8
|
|
||||||
case .block: return 7
|
case .block: return 7
|
||||||
case .neighborhood: return 6
|
case .neighborhood: return 6
|
||||||
case .city: return 5
|
case .city: return 5
|
||||||
@@ -23,7 +21,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .building: return "Building"
|
|
||||||
case .block: return "Block"
|
case .block: return "Block"
|
||||||
case .neighborhood: return "Neighborhood"
|
case .neighborhood: return "Neighborhood"
|
||||||
case .city: return "City"
|
case .city: return "City"
|
||||||
@@ -38,7 +35,6 @@ extension GeohashChannelLevel {
|
|||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
if let raw = try? container.decode(String.self) {
|
if let raw = try? container.decode(String.self) {
|
||||||
switch raw {
|
switch raw {
|
||||||
case "building": self = .building
|
|
||||||
case "block": self = .block
|
case "block": self = .block
|
||||||
case "neighborhood": self = .neighborhood
|
case "neighborhood": self = .neighborhood
|
||||||
case "city": self = .city
|
case "city": self = .city
|
||||||
@@ -50,7 +46,6 @@ extension GeohashChannelLevel {
|
|||||||
}
|
}
|
||||||
} else if let precision = try? container.decode(Int.self) {
|
} else if let precision = try? container.decode(Int.self) {
|
||||||
switch precision {
|
switch precision {
|
||||||
case 8: self = .building
|
|
||||||
case 7: self = .block
|
case 7: self = .block
|
||||||
case 6: self = .neighborhood
|
case 6: self = .neighborhood
|
||||||
case 5: self = .city
|
case 5: self = .city
|
||||||
@@ -66,7 +61,6 @@ extension GeohashChannelLevel {
|
|||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
switch self {
|
switch self {
|
||||||
case .building: try container.encode("building")
|
|
||||||
case .block: try container.encode("block")
|
case .block: try container.encode("block")
|
||||||
case .neighborhood: try container.encode("neighborhood")
|
case .neighborhood: try container.encode("neighborhood")
|
||||||
case .city: try container.encode("city")
|
case .city: try container.encode("city")
|
||||||
|
|||||||
@@ -291,12 +291,6 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
dict[.block] = locality
|
dict[.block] = locality
|
||||||
}
|
}
|
||||||
// Building: prefer place name/street address without numbers where possible
|
|
||||||
if let name = pm.name, !name.isEmpty {
|
|
||||||
dict[.building] = name
|
|
||||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
|
||||||
dict[.building] = thoroughfare
|
|
||||||
}
|
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var geohash: String? = nil
|
@Published private(set) var geohash: String? = nil
|
||||||
@Published private(set) var count: Int? = nil
|
@Published private(set) var count: Int? = nil
|
||||||
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
|
||||||
private var subscriptionID: String? = nil
|
private var subscriptionID: String? = nil
|
||||||
private var noteIDs = Set<String>()
|
private var noteIDs = Set<String>()
|
||||||
@@ -20,12 +21,13 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
geohash = norm
|
geohash = norm
|
||||||
count = nil
|
count = nil
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
|
initialLoadComplete = false
|
||||||
|
|
||||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
||||||
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)
|
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relays) { [weak self] event in
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relays, 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 }
|
||||||
@@ -33,7 +35,9 @@ 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
|
||||||
|
self?.initialLoadComplete = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
@@ -46,4 +50,3 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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
|
||||||
private var subscriptionID: String?
|
private var subscriptionID: String?
|
||||||
|
|
||||||
init(geohash: String) {
|
init(geohash: String) {
|
||||||
@@ -47,7 +48,8 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
// 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 relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relays) { [weak self] event in
|
initialLoadComplete = false
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relays, 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
|
||||||
@@ -58,7 +60,9 @@ 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?.initialLoadComplete = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a location note for the current geohash using the per-geohash identity.
|
/// Send a location note for the current geohash using the per-geohash identity.
|
||||||
|
|||||||
Reference in New Issue
Block a user