diff --git a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift index 1ab911ff..959bbdbd 100644 --- a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift @@ -35,6 +35,16 @@ class NostrNetworkManager { let reader: SubscriptionManager let profilesManager: ProfilesManager + /// Tracks whether the network manager has completed its initial connection + private var isConnected = false + /// A list of continuations waiting for connection to complete + /// + /// We use a unique ID for each connection request so that multiple concurrent calls to `awaitConnection()` + /// can be properly tracked and resumed. This follows the pattern established in `RelayConnection` and `WalletModel`. + private var connectionContinuations: [UUID: CheckedContinuation] = [:] + /// A lock to ensure thread-safe access to the continuations dictionary and connection state + private let continuationsLock = NSLock() + init(delegate: Delegate, addNdbToRelayPool: Bool = true) { self.delegate = delegate let pool = RelayPool(ndb: addNdbToRelayPool ? delegate.ndb : nil, keypair: delegate.keypair) @@ -54,10 +64,110 @@ class NostrNetworkManager { await self.userRelayList.connect() // Will load the user's list, apply it, and get RelayPool to connect to it. await self.profilesManager.load() await self.reader.startPreloader() + + continuationsLock.lock() + isConnected = true + continuationsLock.unlock() + + resumeAllConnectionContinuations() + } + + /// Waits for the app to be connected to the network by checking for the next `connect()` call to complete + /// + /// This method allows code to await the app to load the relay list and connect to it. + /// It uses Swift continuations to handle completion notifications from potentially different threads. + /// + /// - Parameter timeout: Optional timeout duration (defaults to 30 seconds) + /// + /// ## Usage + /// ```swift + /// await nostrNetworkManager.awaitConnection() + /// // Code here runs after connection is established + /// ``` + /// + /// ## Implementation notes + /// + /// - Thread-safe: Can be called from any thread and will handle synchronization properly + /// - Multiple callers: Supports multiple concurrent calls, each tracked by a unique ID + /// - Timeout handling: Automatically resumes after timeout even if connection fails + /// - Short-circuits immediately if already connected, preventing unnecessary waiting + func awaitConnection(timeout: Duration = .seconds(30)) async { + // Short-circuit if already connected + continuationsLock.lock() + let alreadyConnected = isConnected + continuationsLock.unlock() + + guard !alreadyConnected else { + return + } + + let requestId = UUID() + var timeoutTask: Task? + var isResumed = false + + await withCheckedContinuation { (continuation: CheckedContinuation) in + // Store the continuation in a thread-safe manner + continuationsLock.lock() + connectionContinuations[requestId] = continuation + continuationsLock.unlock() + + // Set up timeout + timeoutTask = Task { + try? await Task.sleep(for: timeout) + if !isResumed { + self.resumeConnectionContinuation(requestId: requestId, isResumed: &isResumed) + } + } + } + + timeoutTask?.cancel() + } + + /// Resumes a connection continuation in a thread-safe manner + /// + /// This can be called from any thread and ensures the continuation is only resumed once + /// + /// - Parameters: + /// - requestId: The unique identifier for this connection request + /// - isResumed: Flag to track if the continuation has already been resumed + private func resumeConnectionContinuation(requestId: UUID, isResumed: inout Bool) { + continuationsLock.lock() + defer { continuationsLock.unlock() } + + guard !isResumed, let continuation = connectionContinuations[requestId] else { + return + } + + isResumed = true + connectionContinuations.removeValue(forKey: requestId) + continuation.resume() + } + + /// Resumes all pending connection continuations in a thread-safe manner + /// + /// This is useful for notifying all waiting callers when the connection is established + /// or when you need to unblock all pending connection requests. + /// + /// This can be called from any thread and ensures all continuations are resumed safely. + private func resumeAllConnectionContinuations() { + continuationsLock.lock() + defer { continuationsLock.unlock() } + + // Resume all pending continuations + for (_, continuation) in connectionContinuations { + continuation.resume() + } + + // Clear the dictionary + connectionContinuations.removeAll() } func disconnectRelays() async { await self.pool.disconnect() + + continuationsLock.lock() + isConnected = false + continuationsLock.unlock() } func handleAppBackgroundRequest() async { @@ -88,6 +198,10 @@ class NostrNetworkManager { for await value in group { continue } await pool.close() } + + continuationsLock.lock() + isConnected = false + continuationsLock.unlock() } func ping() async { diff --git a/damus/Features/Search/Models/SearchHomeModel.swift b/damus/Features/Search/Models/SearchHomeModel.swift index de0f4af9..7e964b39 100644 --- a/damus/Features/Search/Models/SearchHomeModel.swift +++ b/damus/Features/Search/Models/SearchHomeModel.swift @@ -55,6 +55,8 @@ class SearchHomeModel: ObservableObject { DispatchQueue.main.async { self.loading = true } + await damus_state.nostrNetwork.awaitConnection() + let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors .map { $0.url } .filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) } diff --git a/damus/Features/Search/Views/SearchHomeView.swift b/damus/Features/Search/Views/SearchHomeView.swift index 58f96835..eb4320bb 100644 --- a/damus/Features/Search/Views/SearchHomeView.swift +++ b/damus/Features/Search/Views/SearchHomeView.swift @@ -14,7 +14,6 @@ struct SearchHomeView: View { @StateObject var model: SearchHomeModel @State var search: String = "" @FocusState private var isFocused: Bool - @State var loadingTask: Task? func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) { var filters = ContentFilters.defaults(damus_state: damus_state) @@ -118,13 +117,8 @@ struct SearchHomeView: View { .onReceive(handle_notify(.new_mutes)) { _ in self.model.filter_muted() } - .onAppear { - if model.events.events.isEmpty { - loadingTask = Task { await model.load() } - } - } - .onDisappear { - loadingTask?.cancel() + .task { + await model.load() } } }