From 96fb909d83d3bea7cc01fedee6674bb180c26482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Fri, 16 Jan 2026 17:25:16 -0800 Subject: [PATCH] Add pull to refresh feature in DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: https://github.com/damus-io/damus/issues/3352 Changelog-Added: Added a pull to refresh feature on DMs that allows users to resync DMs with their relays Signed-off-by: Daniel D’Aquino --- damus/ContentView.swift | 2 +- damus/Core/Nostr/RelayConnection.swift | 4 +- damus/Core/Nostr/RelayPool.swift | 8 ++-- .../DMs/Views/DirectMessagesView.swift | 11 ++++- .../Features/Timeline/Models/HomeModel.swift | 29 +++++++++++- .../SubscriptionManagerNegentropyTests.swift | 47 ++++++++++++++++++- 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 1405593e..24b694e8 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -180,7 +180,7 @@ struct ContentView: View { NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle) case .dms: - DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle) + DirectMessagesView(damus_state: damus_state!, home: home, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle) } } .background(DamusColors.adaptableWhite) diff --git a/damus/Core/Nostr/RelayConnection.swift b/damus/Core/Nostr/RelayConnection.swift index ecd92655..0f913757 100644 --- a/damus/Core/Nostr/RelayConnection.swift +++ b/damus/Core/Nostr/RelayConnection.swift @@ -270,7 +270,7 @@ final class RelayConnection: ObservableObject { } return } - print("failed to decode event \(messageString)") + print("\(self.relay_url): failed to decode event \(messageString)") case .data(let messageData): if let messageString = String(data: messageData, encoding: .utf8) { await receive(message: .string(messageString)) @@ -299,7 +299,7 @@ final class RelayConnection: ObservableObject { throw NegentropySyncError.notSupported } } - let timeout = timeout ?? .seconds(5) + let timeout = timeout ?? .seconds(3) let frameSizeLimit = 60_000 // Copied from rust-nostr project: Default frame limit is 128k. Halve that (hex encoding) and subtract a bit (JSON msg overhead) try? negentropyVector.seal() // Error handling note: We do not care if it throws an `alreadySealed` error. As long as it is sealed in the end it is fine let negentropyClient = try Negentropy(storage: negentropyVector, frameSizeLimit: frameSizeLimit) diff --git a/damus/Core/Nostr/RelayPool.swift b/damus/Core/Nostr/RelayPool.swift index 0ea5f690..9dcfc362 100644 --- a/damus/Core/Nostr/RelayPool.swift +++ b/damus/Core/Nostr/RelayPool.swift @@ -661,10 +661,12 @@ class RelayPool { } } catch { - if let negentropyError = error as? RelayConnection.NegentropySyncError, - case .notSupported = negentropyError, - ignoreUnsupportedRelays { + if ignoreUnsupportedRelays { // Do not throw error, ignore the relays that do not support negentropy + // Note: Some relays such as wss://nos.lol/v2 advertise negentropy but throw an error such as `["NOTICE","ERROR: bad msg: negentropy disabled"]` + // Therefore, realistically, we cannot rely on what the relay advertises and + // we have to suppress those errors if we want to ignore unsupported relays to avoid the whole multi-relay negentropy syncing operation to fail + Log.error("Error while negentropy streaming: %s", for: .networking, error.localizedDescription) } else { throw error diff --git a/damus/Features/DMs/Views/DirectMessagesView.swift b/damus/Features/DMs/Views/DirectMessagesView.swift index d431fa61..59d9431f 100644 --- a/damus/Features/DMs/Views/DirectMessagesView.swift +++ b/damus/Features/DMs/Views/DirectMessagesView.swift @@ -15,7 +15,8 @@ enum DMType: Hashable { struct DirectMessagesView: View { let damus_state: DamusState - + let home: HomeModel + @State var dm_type: DMType = .friend @ObservedObject var model: DirectMessagesModel @ObservedObject var settings: UserSettingsStore @@ -37,6 +38,12 @@ struct DirectMessagesView: View { } .padding(.horizontal) } + .refreshable { + // Fetch full DM history without the `since` optimization. + // This allows users to manually sync older DMs that may have + // been missed due to the optimized network filter. + await home.fetchFullDMHistory() + } .padding(.bottom, tabHeight) } @@ -136,6 +143,6 @@ func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageMo struct DirectMessagesView_Previews: PreviewProvider { static var previews: some View { let ds = test_damus_state - DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings, subtitle: .constant(nil)) + DirectMessagesView(damus_state: ds, home: HomeModel(), model: ds.dms, settings: ds.settings, subtitle: .constant(nil)) } } diff --git a/damus/Features/Timeline/Models/HomeModel.swift b/damus/Features/Timeline/Models/HomeModel.swift index 247977ab..84c6ed27 100644 --- a/damus/Features/Timeline/Models/HomeModel.swift +++ b/damus/Features/Timeline/Models/HomeModel.swift @@ -897,7 +897,34 @@ class HomeModel: ContactsDelegate, ObservableObject { create_local_notification(profiles: damus_state.profiles, notify: notification_object) } } - + + /// Fetches DM history from relays. + /// + /// By default, the DM subscription uses `optimizeNetworkFilter: true` which adds a + /// `since` parameter based on the latest local timestamp. This is efficient but can + /// miss older DMs if the local database doesn't have complete history. + /// + /// This method requests full DM history with negentropy. + func fetchFullDMHistory() async { + // DMs sent to us (limit to prevent runaway pulls; user can pull again for more) + var dms_filter = NostrFilter(kinds: [.dm]) + dms_filter.pubkeys = [damus_state.pubkey] + dms_filter.limit = 500 + + // DMs we sent + var our_dms_filter = NostrFilter(kinds: [.dm]) + our_dms_filter.authors = [damus_state.pubkey] + our_dms_filter.limit = 500 + + let filters = [dms_filter, our_dms_filter] + let timeoutSeconds: UInt64 = 20 + + for await lender in self.damus_state.nostrNetwork.reader.streamExistingEvents(filters: filters, timeout: .seconds(timeoutSeconds), streamMode: .ndbAndNetworkParallel(networkOptimization: .negentropy)) { + if Task.isCancelled { return } + lender.justUseACopy({ self.process_event(ev: $0, context: .other) }) + } + } + @MainActor func handle_dm(_ ev: NostrEvent) { guard should_show_event(state: damus_state, ev: ev) else { diff --git a/damusTests/SubscriptionManagerNegentropyTests.swift b/damusTests/SubscriptionManagerNegentropyTests.swift index 56e66922..163fed60 100644 --- a/damusTests/SubscriptionManagerNegentropyTests.swift +++ b/damusTests/SubscriptionManagerNegentropyTests.swift @@ -134,7 +134,7 @@ final class SubscriptionManagerNegentropyTests: XCTestCase { negentropyEventExpectations: [NoteId: XCTestExpectation], ndbEoseExpectation: XCTestExpectation? = nil, networkEoseExpectation: XCTestExpectation? = nil, - eoseExpectation: XCTestExpectation? = nil + eoseExpectation: XCTestExpectation? = nil, ) { Task { var ndbEoseSeen = false @@ -467,6 +467,51 @@ final class SubscriptionManagerNegentropyTests: XCTestCase { // (Order not enforced because we don't make guarantees on the order of A/C and B/D await fulfillment(of: [getsNoteAFromNdb, getsNoteCFromNdb, ndbEose, getsNoteBFromNegentropy, getsNoteDFromNegentropy, networkEose], timeout: 10.0) } + + func testPartialUnsupportedRelayPool() async throws { + // Given: Two relays (one with negentropy, another one not), and the one with negentropy has an event we need + let relay2 = try await setupRelay(port: 9092) + + let relayUrl1 = RelayURL("ws://nos.lol/v2")! // This can be any relay that does not support negentropy + // Adding an external relay may cause flakiness if the relay enables negentropy, but currently + // there is no feasible way to configure a local relay that rejects negentropy requests. + // Therefore, keep this external relay until it causes issues and then we can investigate + // how to improve this test's robustness. + let relayUrl2 = RelayURL(await relay2.url().description)! + + let noteA = NostrEvent(content: "A", keypair: test_keypair)! + let noteB = NostrEvent(content: "B", keypair: test_keypair)! + + // Connect to relay1 and send noteA + noteB + let relayConnection2 = await connectToRelay(url: relayUrl2, label: "Relay1") + sendEvents([noteA, noteB], to: relayConnection2) + + let ndb = await test_damus_state.ndb + storeEventsInNdb([noteB], ndb: ndb) + + let networkManager = try await setupNetworkManager(with: [relayUrl1, relayUrl2], ndb: ndb) + + let getsNoteBFromNdb = XCTestExpectation(description: "Gets note B from NDB before ndbEose") + let getsNoteAFromNegentropy = XCTestExpectation(description: "Gets note A via negentropy after ndbEose") + let ndbEose = XCTestExpectation(description: "Receives NDB EOSE") + let networkEose = XCTestExpectation(description: "Receives network EOSE") + let generalEose = XCTestExpectation(description: "Receives general EOSE") + + // When: Using negentropy streaming mode across two relays + runAdvancedStream( + networkManager: networkManager, + filters: [NostrFilter(kinds: [.text])], + streamMode: .ndbAndNetworkParallel(networkOptimization: .negentropy), + ndbEventExpectations: [noteB.id: getsNoteBFromNdb], + negentropyEventExpectations: [noteA.id: getsNoteAFromNegentropy], + ndbEoseExpectation: ndbEose, + networkEoseExpectation: networkEose, + eoseExpectation: generalEose + ) + + // Then: Should receive noteB from NDB, then ndbEose, then noteA via negentropy + await fulfillment(of: [getsNoteBFromNdb, ndbEose, getsNoteAFromNegentropy, networkEose, generalEose], timeout: 10.0) + } } // MARK: - Test Doubles