From bb321b6e8a974327684decdbb294a9641d5f63d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 22 Apr 2024 23:09:27 +0000 Subject: [PATCH 1/6] contacts: save the users' latest contact event ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ... to a persistent setting, and try to load it from NostrDB on app start. This commit causes the user's contact list event ID to be saved persistently as a user-specific setting, and to be loaded immediately after startup from the local NostrDB instance. This helps improve reliability around contact lists, since we previously relied on fetching that contact list from other relays. Eventually we will not need the event ID to be stored at all, as we will be able to query NostrDB, but for now having the latest event ID persistently stored will allow us to get around this limitation in the cleanest possible way (i.e. without having to store the event itself into another mechanism, and migrating it later to NostrDB) Other notes: - It uses a mechanism similar to other user settings, so it is pubkey-specific and should handle login/logout cases Signed-off-by: Daniel D’Aquino Reviewed-by: William Casarin Link: 20240422230912.65056-2-daniel@daquino.me Signed-off-by: William Casarin --- damus/Models/Contacts.swift | 14 ++++++++++-- damus/Models/HomeModel.swift | 34 ++++++++++++++++++++++++++-- damus/Models/UserSettingsStore.swift | 6 +++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/damus/Models/Contacts.swift b/damus/Models/Contacts.swift index e411ef0d..6abda61b 100644 --- a/damus/Models/Contacts.swift +++ b/damus/Models/Contacts.swift @@ -7,7 +7,6 @@ import Foundation - class Contacts { private var friends: Set = Set() private var friend_of_friends: Set = Set() @@ -15,7 +14,13 @@ class Contacts { private var pubkey_to_our_friends = [Pubkey : Set]() let our_pubkey: Pubkey - var event: NostrEvent? + var delegate: ContactsDelegate? = nil + var event: NostrEvent? { + didSet { + guard let event else { return } + self.delegate?.latest_contact_event_changed(new_event: event) + } + } init(our_pubkey: Pubkey) { self.our_pubkey = our_pubkey @@ -88,3 +93,8 @@ class Contacts { return Array((pubkey_to_our_friends[pubkey] ?? Set())) } } + +/// Delegate protocol for `Contacts`. Use this to listen to significant updates from a `Contacts` instance +protocol ContactsDelegate { + func latest_contact_event_changed(new_event: NostrEvent) +} diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index a86b1eeb..ee460ad0 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -41,11 +41,15 @@ enum HomeResubFilter { } } -class HomeModel { +class HomeModel: ContactsDelegate { // Don't trigger a user notification for events older than a certain age static let event_max_age_for_notification: TimeInterval = EVENT_MAX_AGE_FOR_NOTIFICATION - var damus_state: DamusState + var damus_state: DamusState { + didSet { + self.load_our_stuff_from_damus_state() + } + } // NDBTODO: let's get rid of this entirely, let nostrdb handle it var has_event: [String: Set] = [:] @@ -108,6 +112,32 @@ class HomeModel { self.should_debounce_dms = false } } + + // MARK: - Loading items from DamusState + + /// This is called whenever DamusState gets set. This function is used to load or setup anything we need from the new DamusState + func load_our_stuff_from_damus_state() { + self.load_latest_contact_event_from_damus_state() + } + + /// This loads the latest contact event we have on file from NostrDB. This should be called as soon as we get the new DamusState + /// Loading the latest contact list event into our `Contacts` instance from storage is important to avoid getting into weird states when the network is unreliable or when relays delete such information + func load_latest_contact_event_from_damus_state() { + guard let latest_contact_event_id_hex = damus_state.settings.latest_contact_event_id_hex else { return } + guard let latest_contact_event_id = NoteId(hex: latest_contact_event_id_hex) else { return } + guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note( latest_contact_event_id)?.unsafeUnownedValue?.to_owned() else { return } + process_contact_event(state: damus_state, ev: latest_contact_event) + damus_state.contacts.delegate = self + } + + // MARK: - ContactsDelegate functions + + func latest_contact_event_changed(new_event: NostrEvent) { + // When the latest user contact event has changed, save its ID so we know exactly where to find it next time + damus_state.settings.latest_contact_event_id_hex = new_event.id.hex() + } + + // MARK: - Nostr event and subscription handling func resubscribe(_ resubbing: Resubscribe) { if self.should_debounce_dms { diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index 503f60c2..351bc108 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -312,6 +312,12 @@ class UserSettingsStore: ObservableObject { return internal_winetranslate_api_key != nil } } + + // MARK: Internal, hidden settings + + @Setting(key: "latest_contact_event_id", default_value: nil) + var latest_contact_event_id_hex: String? + } func pk_setting_key(_ pubkey: Pubkey, key: String) -> String { From c8aba00f85b370bf66ea46306a0bdde1aa887b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 22 Apr 2024 23:09:30 +0000 Subject: [PATCH 2/6] contacts: save first list to storage during onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a mechanism to add the contact list to storage as soon as it is generated, and thus it reduces the risk of poor network conditions causing issues. Changelog-Fixed: Improve reliability of contact list creation during onboarding Closes: https://github.com/damus-io/damus/issues/2057 Signed-off-by: Daniel D’Aquino Reviewed-by: William Casarin Link: 20240422230912.65056-3-daniel@daquino.me Signed-off-by: William Casarin --- damus/ContentView.swift | 5 +---- damus/Models/UserSettingsStore.swift | 8 ++++++++ damus/Nostr/RelayPool.swift | 22 +++++++++++++--------- damus/Views/SaveKeysView.swift | 28 +++++++++++++++++++++++++--- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 0a130075..e8efd7d2 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -679,10 +679,7 @@ struct ContentView: View { let relay_filters = RelayFilters(our_pubkey: pubkey) let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey) - // dumb stuff needed for property wrappers - UserSettingsStore.pubkey = pubkey - let settings = UserSettingsStore() - UserSettingsStore.shared = settings + let settings = UserSettingsStore.globally_load_for(pubkey: pubkey) let new_relay_filters = load_relay_filters(pubkey) == nil for relay in bootstrap_relays { diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index 351bc108..8abaa257 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -96,6 +96,14 @@ class UserSettingsStore: ObservableObject { static var shared: UserSettingsStore? = nil static var bool_options = Set() + static func globally_load_for(pubkey: Pubkey) -> UserSettingsStore { + // dumb stuff needed for property wrappers + UserSettingsStore.pubkey = pubkey + let settings = UserSettingsStore() + UserSettingsStore.shared = settings + return settings + } + @StringSetting(key: "default_wallet", default_value: .system_default_wallet) var default_wallet: Wallet diff --git a/damus/Nostr/RelayPool.swift b/damus/Nostr/RelayPool.swift index 6d9a1210..903b3f76 100644 --- a/damus/Nostr/RelayPool.swift +++ b/damus/Nostr/RelayPool.swift @@ -226,19 +226,23 @@ class RelayPool { print("queueing request for \(relay)") request_queue.append(QueuedRequest(req: r, relay: relay, skip_ephemeral: skip_ephemeral)) } + + func send_raw_to_local_ndb(_ req: NostrRequestType) { + // send to local relay (nostrdb) + switch req { + case .typical(let r): + if case .event = r, let rstr = make_nostr_req(r) { + let _ = ndb.process_client_event(rstr) + } + case .custom(let string): + let _ = ndb.process_client_event(string) + } + } func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) { let relays = to.map{ get_relays($0) } ?? self.relays - // send to local relay (nostrdb) - switch req { - case .typical(let r): - if case .event = r, let rstr = make_nostr_req(r) { - let _ = ndb.process_client_event(rstr) - } - case .custom(let string): - let _ = ndb.process_client_event(string) - } + self.send_raw_to_local_ndb(req) for relay in relays { if req.is_read && !(relay.descriptor.info.read ?? true) { diff --git a/damus/Views/SaveKeysView.swift b/damus/Views/SaveKeysView.swift index 4d7b6399..93796e15 100644 --- a/damus/Views/SaveKeysView.swift +++ b/damus/Views/SaveKeysView.swift @@ -21,6 +21,13 @@ struct SaveKeysView: View { @FocusState var pubkey_focused: Bool @FocusState var privkey_focused: Bool + let first_contact_event: NdbNote? + + init(account: CreateAccountModel) { + self.account = account + self.first_contact_event = make_first_contact_event(keypair: account.keypair) + } + var body: some View { ZStack(alignment: .top) { VStack(alignment: .center) { @@ -102,6 +109,13 @@ struct SaveKeysView: View { } func complete_account_creation(_ account: CreateAccountModel) { + guard let first_contact_event else { + error = NSLocalizedString("Could not create your initial contact list event. This is a software bug, please contact Damus support via support@damus.io or through our Nostr account for help.", comment: "Error message to the user indicating that the initial contact list failed to be created.") + return + } + // Save contact list to storage right away so that we don't need to depend on the network to complete this important step + self.save_to_storage(first_contact_event: first_contact_event, for: account) + let bootstrap_relays = load_bootstrap_relays(pubkey: account.pubkey) for relay in bootstrap_relays { add_rw_relay(self.pool, relay) @@ -115,6 +129,15 @@ struct SaveKeysView: View { self.pool.connect() } + + func save_to_storage(first_contact_event: NdbNote, for account: CreateAccountModel) { + // Send to NostrDB so that we have a local copy in storage + self.pool.send_raw_to_local_ndb(.typical(.event(first_contact_event))) + + // Save the ID to user settings so that we can easily find it later. + let settings = UserSettingsStore.globally_load_for(pubkey: account.pubkey) + settings.latest_contact_event_id_hex = first_contact_event.id.hex() + } func handle_event(relay: RelayURL, ev: NostrConnectionEvent) { switch ev { @@ -122,15 +145,14 @@ struct SaveKeysView: View { switch wsev { case .connected: let metadata = create_account_to_metadata(account) - let contacts_ev = make_first_contact_event(keypair: account.keypair) if let keypair = account.keypair.to_full(), let metadata_ev = make_metadata_event(keypair: keypair, metadata: metadata) { self.pool.send(.event(metadata_ev)) } - if let contacts_ev { - self.pool.send(.event(contacts_ev)) + if let first_contact_event { + self.pool.send(.event(first_contact_event)) } do { From a9a2a52881c9abe2efd8dc0d0456f562874267f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 22 Apr 2024 23:09:37 +0000 Subject: [PATCH 3/6] ui: add First Aid view to settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit also create the contact list reset First Aid action Automatically detecting whether or not to create a blank contact list when we could not find any is very tricky. It could mean that no contact list exists, but it could also mean that a temporary network or relay outage occurred. Since resetting the contact list when one already exists is a destructive action, we should make no assumptions. Instead, we should provide users the tool to fix it based on their own judgement. For that reason, the first aid view was created. It detects if no contact list was found, and in those cases, it gives them an option to reset (with appropriate warning messages). Testing 1: Contact list creation robustness ----------------------------- Setup: 1. Network Link Conditioner installed and configured to this profile: - DNS delay: 400 ms - Downlink bandwidth: 100 kbps - Uplink bandwidth: 50 kbps - Packets dropped: 50% (On both uplink and downlink) - Delay: 1000 ms (Both uplink and downlink) Procedure: 1. Turn Network Link conditioner ON 2. Go through the account creation steps 3. At the moment the onboarding follow suggestions screen shows up, quit the app 3. Turn Network Link conditioner OFF 4. Start the app again 5. Verify the home screen. It should present notes from the Damus account (the default follow) 6. Follow someone and wait for 5 seconds 7. Restart app 8. Look at the home feed. Notes from user from step 6 should appear, and that user should appear as being followed by you. - Repro details: - Damus version: ada99418f6fcdb1354bc5c1c3f3cc3b4db994ce6 - Device: iPhone 15 simulator - iOS: 17.4 - Number of runs: 3 times - Result: FAILS (issue is reproduced) 3 out of 3 times - Test details: - Damus version: This commit - Device: iPhone 15 simulator - iOS: 17.4 - Number of runs: 3 times - Result: PASSES all criteria 3 out of 3 times Testing 2: Contact list First Aid ------------------------------ Setup: 1. Reproduce the issue with the old version as outlined in "Testing 1" above 2. Upgrade to the version in this commit Steps: 1. Go to Settings > First Aid 2. A button to reset the contact list (and some text for context) should appear. PASS 3. Click on the button. A warning message should appear. PASS 4. Click "cancel". The action should be cancelled and nothing should have changed. PASS 5. Click on the reset button again. 6. Click "Continue" on the warning prompt. The reset button will now show "Contact list has been reset" with a green checkmark. PASS 5. Go back to the home tab. Notes from the Damus account should immediately appear. PASS 6. Try to follow someone and restart the app. Follows should now stick persistently. PASS 7. Go to the First Aid screen again. The reset option should no longer be present. PASS Changelog-Added: Add First Aid solution for users who do not have a contact list created for their account Signed-off-by: Daniel D’Aquino Reviewed-by: William Casarin Link: 20240422230912.65056-4-daniel@daquino.me Signed-off-by: William Casarin --- damus.xcodeproj/project.pbxproj | 4 + damus/Util/Router.swift | 5 ++ damus/Views/ConfigView.swift | 4 + .../Views/Settings/FirstAidSettingsView.swift | 84 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 damus/Views/Settings/FirstAidSettingsView.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index e5fd8ca3..e027c3be 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -639,6 +639,7 @@ D7EDED332B12ACAE0018B19C /* DamusUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */; }; D7EDED342B12ACAE0018B19C /* DamusUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */; }; D7FB10A72B0C371A00FA8D42 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2B10272A7B0F5C008AA43E /* Log.swift */; }; + D7FD12262BD345A700CF195B /* FirstAidSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FD12252BD345A700CF195B /* FirstAidSettingsView.swift */; }; D7FF94002AC7AC5300FD969D /* RelayURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FF93FF2AC7AC5200FD969D /* RelayURL.swift */; }; E02429952B7E97740088B16C /* CameraController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E02429942B7E97740088B16C /* CameraController.swift */; }; E02B54182B4DFADA0077FF42 /* Bech32ObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E02B54172B4DFADA0077FF42 /* Bech32ObjectTests.swift */; }; @@ -1433,6 +1434,7 @@ D7EDED202B117DCA0018B19C /* SequenceUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceUtils.swift; sourceTree = ""; }; D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtension.swift; sourceTree = ""; }; D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusUserDefaults.swift; sourceTree = ""; }; + D7FD12252BD345A700CF195B /* FirstAidSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstAidSettingsView.swift; sourceTree = ""; }; D7FF93FF2AC7AC5200FD969D /* RelayURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayURL.swift; sourceTree = ""; }; E02429942B7E97740088B16C /* CameraController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraController.swift; sourceTree = ""; }; E02B54172B4DFADA0077FF42 /* Bech32ObjectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Bech32ObjectTests.swift; sourceTree = ""; }; @@ -1724,6 +1726,7 @@ 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */, BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */, BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */, + D7FD12252BD345A700CF195B /* FirstAidSettingsView.swift */, ); path = Settings; sourceTree = ""; @@ -3106,6 +3109,7 @@ D7EDED1E2B11797D0018B19C /* LongformEvent.swift in Sources */, 504323A92A3495B6006AE6DC /* RelayModelCache.swift in Sources */, 3A8CC6CC2A2CFEF900940F5F /* StringUtil.swift in Sources */, + D7FD12262BD345A700CF195B /* FirstAidSettingsView.swift in Sources */, D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */, 4CB55EF5295E679D007FD187 /* UserRelaysView.swift in Sources */, 4C363AA228296A7E006E126D /* SearchView.swift in Sources */, diff --git a/damus/Util/Router.swift b/damus/Util/Router.swift index 0a341d9b..b737446b 100644 --- a/damus/Util/Router.swift +++ b/damus/Util/Router.swift @@ -30,6 +30,7 @@ enum Route: Hashable { case ReactionsSettings(settings: UserSettingsStore) case SearchSettings(settings: UserSettingsStore) case DeveloperSettings(settings: UserSettingsStore) + case FirstAidSettings(settings: UserSettingsStore) case Thread(thread: ThreadModel) case Reposts(reposts: EventsModel) case QuoteReposts(quotes: EventsModel) @@ -89,6 +90,8 @@ enum Route: Hashable { SearchSettingsView(settings: settings) case .DeveloperSettings(let settings): DeveloperSettingsView(settings: settings) + case .FirstAidSettings(settings: let settings): + FirstAidSettingsView(damus_state: damusState, settings: settings) case .Thread(let thread): ThreadView(state: damusState, thread: thread) case .Reposts(let reposts): @@ -175,6 +178,8 @@ enum Route: Hashable { hasher.combine("searchSettings") case .DeveloperSettings: hasher.combine("developerSettings") + case .FirstAidSettings: + hasher.combine("firstAidSettings") case .Thread(let threadModel): hasher.combine("thread") hasher.combine(threadModel.event.id) diff --git a/damus/Views/ConfigView.swift b/damus/Views/ConfigView.swift index 6ffaf066..c6e331c3 100644 --- a/damus/Views/ConfigView.swift +++ b/damus/Views/ConfigView.swift @@ -67,6 +67,10 @@ struct ConfigView: View { NavigationLink(value: Route.DeveloperSettings(settings: settings)) { IconLabel(NSLocalizedString("Developer", comment: "Section header for developer settings"), img_name: "magic-stick2.fill", color: DamusColors.adaptableBlack) } + + NavigationLink(value: Route.FirstAidSettings(settings: settings)) { + IconLabel(NSLocalizedString("First Aid", comment: "Section header for first aid tools and settings"), img_name: "help2", color: .red) + } } Section(NSLocalizedString("Sign Out", comment: "Section title for signing out")) { diff --git a/damus/Views/Settings/FirstAidSettingsView.swift b/damus/Views/Settings/FirstAidSettingsView.swift new file mode 100644 index 00000000..2be6d0d2 --- /dev/null +++ b/damus/Views/Settings/FirstAidSettingsView.swift @@ -0,0 +1,84 @@ +// +// FirstAidSettingsView.swift +// damus +// +// Created by Daniel D’Aquino on 2024-04-19. +// + +import SwiftUI + +struct FirstAidSettingsView: View { + let damus_state: DamusState + @ObservedObject var settings: UserSettingsStore + @State var reset_contact_list_state: ContactListResetState = .not_started + + enum ContactListResetState: Equatable { + case not_started + case confirming_with_user + case error(String) + case in_progress + case completed + } + + + var body: some View { + Form { + if damus_state.contacts.event == nil { + Section( + header: Text(NSLocalizedString("Contact list (Follows + Relay list)", comment: "Section title for Contact list first aid tools")), + footer: Text(NSLocalizedString("No contact list was found. You might experience issues using the app. If you suspect you have permanently lost your contact list (or if you never had one), you can fix this by resetting it", comment: "Section footer for Contact list first aid tools")) + ) { + Button(action: { + reset_contact_list_state = .confirming_with_user + }, label: { + HStack(spacing: 6) { + switch reset_contact_list_state { + case .not_started, .error: + Label(NSLocalizedString("Reset contact list", comment: "Button to reset contact list."), image: "broom") + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundColor(.red) + case .confirming_with_user, .in_progress: + ProgressView() + Text(NSLocalizedString("In progress…", comment: "Loading message indicating that a contact list reset operation is in progress.")) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(NSLocalizedString("Contact list has been reset", comment: "Message indicating that the contact list was successfully reset.")) + } + } + }) + .disabled(reset_contact_list_state == .in_progress || reset_contact_list_state == .completed) + + if case let .error(error_message) = reset_contact_list_state { + Text(error_message) + .foregroundStyle(.red) + } + } + .alert(NSLocalizedString("WARNING:\n\nThis will reset your contact list, including the list of everyone you follow and the list of all relays you usually connect to. ONLY PROCEED IF YOU ARE SURE YOU HAVE LOST YOUR CONTACT LIST BEYOND RECOVERABILITY.", comment: "Alert for resetting the user's contact list."), + isPresented: Binding(get: { reset_contact_list_state == .confirming_with_user }, set: { _ in return }) + ) { + Button(NSLocalizedString("Cancel", comment: "Cancel resetting the contact list."), role: .cancel) { + reset_contact_list_state = .not_started + } + Button(NSLocalizedString("Continue", comment: "Continue with resetting the contact list.")) { + guard let new_contact_list_event = make_first_contact_event(keypair: damus_state.keypair) else { + reset_contact_list_state = .error(NSLocalizedString("An unexpected error happened while trying to create the new contact list. Please contact support.", comment: "Error message for a failed contact list reset operation")) + return + } + damus_state.pool.send(.event(new_contact_list_event)) + reset_contact_list_state = .completed + } + } + } + + if damus_state.contacts.event != nil { + Text(NSLocalizedString("We did not detect any issues that we can automatically fix for you. If you are having issues, please contact Damus support", comment: "Message indicating that no First Aid actions are available.")) + } + } + .navigationTitle(NSLocalizedString("First Aid", comment: "Navigation title for first aid settings and tools")) + } +} + +#Preview { + FirstAidSettingsView(damus_state: test_damus_state, settings: test_damus_state.settings) +} From 862101a3f7a27029b32c7c2be57590ea54d769fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Fri, 26 Apr 2024 23:13:47 +0000 Subject: [PATCH 4/6] Fix Ghost notifications from Damus Purple Impending expiration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the "ghost notifications" experienced by Purple users whose membership has expired (or about to expire). It does that by using a similar mechanism as other notifications to keep track of the last event date seen on the notifications tab in a persistent way. Testing -------- iOS: 17.4 Device: iPhone 15 simulator damus-api: bfe6c4240a0b3729724162896f0024a963586f7c Damus: This commit Setup: 1. Local Purple server 2. Damus running on local testing mode for Purple 3. An existing but expired Purple account (on the local server) Steps: 1. Reopen app after pointing to the new server and setting things up. 2. Check that the bell icon shows there is a new notification. PASS 3. Check that purple expiration notifications are visible. PASS 4. Restart app. 5. Check the bell icon. This time there should be no new notifications. PASS 6. Using another account, engage with the primary test account to cause a new notification to appear. 7. After a second or two, the bell icon should indicate there is a new notification from the other user. PASS 8. Switch out and into the app. Check that the bell icon does not indicate any new notifications. PASS 9. Restart the app again. The bell icon should once again NOT indicate any new notifications. PASS Changelog-Fixed: Fix ghost notifications caused by Purple impending expiration notifications Closes: https://github.com/damus-io/damus/issues/2158 Signed-off-by: Daniel D’Aquino Reviewed-by: William Casarin --- damus/ContentView.swift | 6 ++++++ damus/Models/HomeModel.swift | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index e8efd7d2..b8619102 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -832,6 +832,12 @@ func save_last_event(_ ev: NostrEvent, timeline: Timeline) { UserDefaults.standard.set(String(ev.created_at), forKey: "last_\(str)_time") } +func save_last_event(_ ev_id: NoteId, created_at: UInt32, timeline: Timeline) { + let str = timeline.rawValue + UserDefaults.standard.set(ev_id.hex(), forKey: "last_\(str)") + UserDefaults.standard.set(String(created_at), forKey: "last_\(str)_time") +} + func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] { return filters.map { filter in diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index ee460ad0..82d5bde1 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -309,9 +309,14 @@ class HomeModel: ContactsDelegate { @MainActor func handle_damus_app_notification(_ notification: DamusAppNotification) async { if self.notifications.insert_app_notification(notification: notification) { - // If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits - // This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification - self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue) + let last_notification = get_last_event(.notifications) + if last_notification == nil || last_notification!.created_at < notification.last_event_at { + save_last_event(NoteId.empty, created_at: notification.last_event_at, timeline: .notifications) + // If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits + // This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification + self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue) + } + return } } From 97169f4fa276723bfab28ca304953ec206c904d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 29 Apr 2024 12:45:40 -0700 Subject: [PATCH 5/6] Fix GIF uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes GIF uploads and improves GIF support: - MediaPicker will now skip location data removal processing, as it is not needed on GIF images and causes them to be converted to JPEG images - The uploader now sets more accurate MIME types on the upload request Issue Repro ----------- Device: iPhone 13 Mini iOS: 17.4.1 Damus: `ada99418f6fcdb1354bc5c1c3f3cc3b4db994ce6` Steps: 1. Download a GIF from GIPHY to the iOS photo gallery 2. Upload that and attach into a post in Damus 3. Check if GIF is animated. Results: GIF is not animated. Issue is reproduced. Testing ------- PASS Device: iPhone 13 Mini iOS: 17.4.1 Damus: this commit Steps: 1. Create a new post 2. Upload the same GIF as the repro and post 3. Make sure GIF is animated. PASS 4. Create a new post 5. Upload a new GIF image (that has never been uploaded by the user on the app) and post 6. Make sure the GIF is animated on the post. PASS 7. Make sure that JPEGs can still be successfully uploaded. PASS 8. Make sure that MP4s can be uploaded. 9. Make a new post that contains 1 JPEG, 1 MP4 file, and 2 GIF files. Make sure they are all uploaded correctly and all GIF files are animated. PASS Closes: https://github.com/damus-io/damus/issues/2157 Changelog-Fixed: Fix broken GIF uploads Signed-off-by: Daniel D’Aquino Reviewed-by: William Casarin --- damus/Models/ImageUploadModel.swift | 26 +++++++++++++++++++++++++ damus/Util/Images/ImageProcessing.swift | 6 +++--- damus/Util/Log.swift | 1 + damus/Views/AttachMediaUtility.swift | 2 +- damus/Views/MediaPicker.swift | 24 ++++++++++++++++++++++- 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/damus/Models/ImageUploadModel.swift b/damus/Models/ImageUploadModel.swift index e6a44d22..a8c9ce72 100644 --- a/damus/Models/ImageUploadModel.swift +++ b/damus/Models/ImageUploadModel.swift @@ -49,6 +49,32 @@ enum MediaUpload { return false } + + var mime_type: String { + switch self.file_extension { + case "jpg", "jpeg": + return "image/jpg" + case "png": + return "image/png" + case "gif": + return "image/gif" + case "tiff", "tif": + return "image/tiff" + case "mp4": + return "video/mp4" + case "ogg": + return "video/ogg" + case "webm": + return "video/webm" + default: + switch self { + case .image: + return "image/jpg" + case .video: + return "video/mp4" + } + } + } } class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject { diff --git a/damus/Util/Images/ImageProcessing.swift b/damus/Util/Images/ImageProcessing.swift index 40abb699..a7e26228 100644 --- a/damus/Util/Images/ImageProcessing.swift +++ b/damus/Util/Images/ImageProcessing.swift @@ -30,7 +30,7 @@ func processImage(image: UIImage) -> URL? { } fileprivate func processImage(source: CGImageSource, fileExtension: String) -> URL? { - let destinationURL = createMediaURL(fileExtension: fileExtension) + let destinationURL = generateUniqueTemporaryMediaURL(fileExtension: fileExtension) guard let destination = removeGPSDataFromImage(source: source, url: destinationURL) else { return nil } @@ -45,7 +45,7 @@ func processVideo(videoURL: URL) -> URL? { } fileprivate func saveVideoToTemporaryFolder(videoURL: URL) -> URL? { - let destinationURL = createMediaURL(fileExtension: videoURL.pathExtension) + let destinationURL = generateUniqueTemporaryMediaURL(fileExtension: videoURL.pathExtension) do { try FileManager.default.copyItem(at: videoURL, to: destinationURL) @@ -57,7 +57,7 @@ fileprivate func saveVideoToTemporaryFolder(videoURL: URL) -> URL? { } /// Generate a temporary URL with a unique filename -fileprivate func createMediaURL(fileExtension: String) -> URL { +func generateUniqueTemporaryMediaURL(fileExtension: String) -> URL { let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let uniqueMediaName = "\(UUID().uuidString).\(fileExtension)" let temporaryMediaURL = temporaryDirectoryURL.appendingPathComponent(uniqueMediaName) diff --git a/damus/Util/Log.swift b/damus/Util/Log.swift index a879037f..d8a3208e 100644 --- a/damus/Util/Log.swift +++ b/damus/Util/Log.swift @@ -15,6 +15,7 @@ enum LogCategory: String { case storage case push_notifications case damus_purple + case image_uploading } /// Damus structured logger diff --git a/damus/Views/AttachMediaUtility.swift b/damus/Views/AttachMediaUtility.swift index ecf28cdd..e4f9c60b 100644 --- a/damus/Views/AttachMediaUtility.swift +++ b/damus/Views/AttachMediaUtility.swift @@ -17,7 +17,7 @@ enum ImageUploadResult { fileprivate func create_upload_body(mediaData: Data, boundary: String, mediaUploader: MediaUploader, mediaToUpload: MediaUpload) -> Data { let body = NSMutableData(); - let contentType = mediaToUpload.is_image ? "image/jpg" : "video/mp4" + let contentType = mediaToUpload.mime_type body.appendString(string: "Content-Type: multipart/form-data; boundary=\(boundary)\r\n\r\n") body.appendString(string: "--\(boundary)\r\n") body.appendString(string: "Content-Disposition: form-data; name=\(mediaUploader.nameParam); filename=\(mediaToUpload.genericFileName)\r\n") diff --git a/damus/Views/MediaPicker.swift b/damus/Views/MediaPicker.swift index 43f6fceb..d03598e4 100644 --- a/damus/Views/MediaPicker.swift +++ b/damus/Views/MediaPicker.swift @@ -36,7 +36,29 @@ struct MediaPicker: UIViewControllerRepresentable { result.itemProvider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { (item, error) in guard let url = item as? URL else { return } - if canGetSourceTypeFromUrl(url: url) { + if(url.pathExtension == "gif") { + // GIFs do not natively support location metadata (See https://superuser.com/a/556320 and https://www.w3.org/Graphics/GIF/spec-gif89a.txt) + // It is better to avoid any GPS data processing at all, as it can cause the image to be converted to JPEG. + // Therefore, we should load the file directtly and deliver it as "already processed". + + // Load the data for the GIF image + // - Don't load it as an UIImage since that can only get exported into JPEG/PNG + // - Don't load it as a file representation because it gets deleted before the upload can occur + _ = result.itemProvider.loadDataRepresentation(for: .gif, completionHandler: { imageData, error in + guard let imageData else { return } + let destinationURL = generateUniqueTemporaryMediaURL(fileExtension: "gif") + do { + try imageData.write(to: destinationURL) + Task { + await self.chooseMedia(.processed_image(destinationURL)) + } + } + catch { + Log.error("Failed to write GIF image data from Photo picker into a local copy", for: .image_uploading) + } + }) + } + else if canGetSourceTypeFromUrl(url: url) { // Media was not taken from camera self.attemptAcquireResourceAndChooseMedia( url: url, From 669a313f92a01594437fe67a8c62ec35888b9280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Wed, 1 May 2024 14:27:41 -0700 Subject: [PATCH 6/6] Relays: Always respect user's local relay list when present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes an issue where the Damus relay (Or other bootstrap relays) would be added to the user's relay list even though they explicitly removed it. The root cause of the issue lies in the way we load bootstrap relays. The default bootstrap relays would be initially loaded even though the user already has a bootstrap list stored, just in case all the relays on the user list fails. This would cause the app to inadvertently connect to relays that the user did not select whenever there is a connectivity issue with all their listed relays. The fix is to simply not add the default bootstrap list when the user already has a list stored. We do not need to use bootstrap relays in order to get our relay list, because that list is already stored in both UserDefaults as well as on NostrDB through the user's contact list event. (A contact list which is also locally loaded on startup since the fix related to https://github.com/damus-io/damus/issues/2057) Issue reproduction + Testing ---------------------------- Procedure: 1. Disconnect from all relays, and disconnect from the Damus relay last. 2. Connect to a local relay (that you control). Connection should be successful. 3. Quit the app completely. 4. Stop the local relay. 5. Restart the app. 6. Go to the relay list view. 7. Check the relay list. It should list the one local relay selected by the user Issue reproduction: - Device: iPhone 15 simulator - iOS: 17.4 - Damus: 1.8 (`97169f4fa276723bfab28ca304953ec206c904d2`) - Result: ISSUE REPRODUCED - Details: On step 7, the relay list only lists the Damus relay Fix test: - Device: iPhone 15 simulator - iOS: 17.4 - Damus: This commit - Result: PASS - Details: On step 7, the local relay is listed even though connection is unsuccessful. No notes are loaded since no relays were able to connect successfully Quick regression check ---------------------- PASS Device: iPhone 15 simulator iOS: 17.4 Damus: This commit Steps: 1. Reinstall app from scratch 2. Create a new account, go through onboarding 3. Make sure that new account connects to bootstrap relays. PASS 4. Sign out 5. Sign in with previously existing account (The one from the previous test) (Notice no UserDefaults exists for this user at that point) 6. Make sure relay list is loaded to the latest relay list known to the bootstrap relays (i.e. connects only to the Damus relay) (It cannot recover the latest relay list pointing only to the local relay, since the bootstrap relays have no knowledge about that relay or the contact lists stored there.). PASS Note: The behavior on step 6 is not a bug, it is an expected limitation. In fact, this behavior is privacy protecting, as the user may not want those public relays from knowing about its connection preference to the local relay (and its address) Other information ------------------ Q: How is this test using local relays related or equivalent to Tor relay list described in #2186? A: Those Tor relays need dedicated software (such as Orbot VPN) to be running successfully in order for Damus to make a successful connection to them. If at any moment that VPN stops working, it would trigger the same situation as described in the test above, where all relay connections fail at once. Q: In #2186, the user reports that the Damus relay is added, but does not describe the Damus relay replacing existing relays. What is the difference? A: I believe the difference is in the order in which relays are added or removed. We have to remember that the relay we just disconnected from will likely still have version N-1 of our contact list event, where it still includes itself on that list. Changelog-Fixed: Fix issue where bootstrap relays would inadvertently be added to the user's list on connectivity issues Closes: https://github.com/damus-io/damus/issues/2186 Signed-off-by: Daniel D’Aquino Reviewed-by: William Casarin --- damus/Util/Relays/RelayBootstrap.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damus/Util/Relays/RelayBootstrap.swift b/damus/Util/Relays/RelayBootstrap.swift index 3e828795..ebfba9a9 100644 --- a/damus/Util/Relays/RelayBootstrap.swift +++ b/damus/Util/Relays/RelayBootstrap.swift @@ -58,7 +58,7 @@ func load_bootstrap_relays(pubkey: Pubkey) -> [RelayURL] { let relay_urls = relays.compactMap({ RelayURL($0) }) - let loaded_relays = Array(Set(relay_urls + get_default_bootstrap_relays())) + let loaded_relays = Array(Set(relay_urls)) print("Loading custom bootstrap relays: \(loaded_relays)") return loaded_relays }