damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 0a4e75bfec7d557863f33bdccca76541b37fa646
parent 9fef2f071abada100f095c3453a8f189b3520365
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 29 Mar 2023 19:24:06 -0400

Add nip05 search

Changelog-Added: Added ability to lookup users by nip05 identifiers

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/Models/HomeModel.swift | 1+
Mdamus/Nostr/Profiles.swift | 1+
Adamus/Util/DebouncedOnChange.swift | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Util/NIP05.swift | 23++++++++++++++++++++++-
Mdamus/Views/Search/SearchingEventView.swift | 36+++++++++++++++++++++++++++++++++++-
Mdamus/Views/SearchResultsView.swift | 11+++++++++++
7 files changed, 143 insertions(+), 2 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -181,6 +181,7 @@ 4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE0E2AE29A2E82100DB4CA2 /* EventHolder.swift */; }; 4CE0E2B229A3DF6900DB4CA2 /* LoadMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE0E2B129A3DF6900DB4CA2 /* LoadMoreButton.swift */; }; 4CE0E2B629A3ED5500DB4CA2 /* InnerTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */; }; + 4CE4F0F229D4FCFA005914DB /* DebouncedOnChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F129D4FCFA005914DB /* DebouncedOnChange.swift */; }; 4CE4F8CD281352B30009DFBB /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F8CC281352B30009DFBB /* Notifications.swift */; }; 4CE4F9DE2852768D00C00DD9 /* ConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F9DD2852768D00C00DD9 /* ConfigView.swift */; }; 4CE4F9E1285287B800C00DD9 /* TextFieldAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F9E0285287B800C00DD9 /* TextFieldAlert.swift */; }; @@ -566,6 +567,7 @@ 4CE0E2AE29A2E82100DB4CA2 /* EventHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventHolder.swift; sourceTree = "<group>"; }; 4CE0E2B129A3DF6900DB4CA2 /* LoadMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreButton.swift; sourceTree = "<group>"; }; 4CE0E2B529A3ED5500DB4CA2 /* InnerTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InnerTimelineView.swift; sourceTree = "<group>"; }; + 4CE4F0F129D4FCFA005914DB /* DebouncedOnChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebouncedOnChange.swift; sourceTree = "<group>"; }; 4CE4F8CC281352B30009DFBB /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = "<group>"; }; 4CE4F9DD2852768D00C00DD9 /* ConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigView.swift; sourceTree = "<group>"; }; 4CE4F9E0285287B800C00DD9 /* TextFieldAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldAlert.swift; sourceTree = "<group>"; }; @@ -938,6 +940,7 @@ 3A3040F029A8FF97008A0F29 /* LocalizationUtil.swift */, 4C30AC7729A577AB00E2BD5A /* EventCache.swift */, 4C9BB83029C0ED4F00FC4E37 /* DisplayName.swift */, + 4CE4F0F129D4FCFA005914DB /* DebouncedOnChange.swift */, ); path = Util; sourceTree = "<group>"; @@ -1593,6 +1596,7 @@ 4CFF8F6929CC9ED1008DB934 /* ImageContainerView.swift in Sources */, 4C54AA0729A540BA003E4487 /* NotificationsModel.swift in Sources */, 4CB55EF3295E5D59007FD187 /* RecommendedRelayView.swift in Sources */, + 4CE4F0F229D4FCFA005914DB /* DebouncedOnChange.swift in Sources */, 4CF0ABEC29844B4700D66079 /* AnyDecodable.swift in Sources */, 4C5F9118283D88E40052CD1C /* FollowingModel.swift in Sources */, 643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */, diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -675,6 +675,7 @@ func process_metadata_event(our_pubkey: String, profiles: Profiles, ev: NostrEve DispatchQueue.main.async { profiles.validated[ev.pubkey] = validated + profiles.nip05_pubkey[nip05] = ev.pubkey notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) } } diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift @@ -12,6 +12,7 @@ import UIKit class Profiles { var profiles: [String: TimestampedProfile] = [:] var validated: [String: NIP05] = [:] + var nip05_pubkey: [String: String] = [:] var zappers: [String: String] = [:] func is_validated(_ pk: String) -> NIP05? { diff --git a/damus/Util/DebouncedOnChange.swift b/damus/Util/DebouncedOnChange.swift @@ -0,0 +1,69 @@ +// https://github.com/Tunous/DebouncedOnChange/blob/5670ea13e8ad33e9cc3197f6d13ce492dc0e46ab/Sources/DebouncedOnChange/DebouncedChangeViewModifier.swift + +import SwiftUI +import Foundation + +extension View { + + /// Adds a modifier for this view that fires an action only when a time interval in seconds represented by + /// `debounceTime` elapses between value changes. + /// + /// Each time the value changes before `debounceTime` passes, the previous action will be cancelled and the next + /// action /// will be scheduled to run after that time passes again. This mean that the action will only execute + /// after changes to the value /// stay unmodified for the specified `debounceTime` in seconds. + /// + /// - Parameters: + /// - value: The value to check against when determining whether to run the closure. + /// - debounceTime: The time in seconds to wait after each value change before running `action` closure. + /// - action: A closure to run when the value changes. + /// - Returns: A view that fires an action after debounced time when the specified value changes. + public func onChange<Value>( + of value: Value, + debounceTime: TimeInterval, + perform action: @escaping (_ newValue: Value) -> Void + ) -> some View where Value: Equatable { + self.modifier(DebouncedChangeViewModifier(trigger: value, debounceTime: debounceTime, action: action)) + } +} + +private struct DebouncedChangeViewModifier<Value>: ViewModifier where Value: Equatable { + let trigger: Value + let debounceTime: TimeInterval + let action: (Value) -> Void + + @State private var debouncedTask: Task<Void, Never>? + + func body(content: Content) -> some View { + content.onChange(of: trigger) { value in + debouncedTask?.cancel() + debouncedTask = Task.delayed(seconds: debounceTime) { @MainActor in + action(value) + } + } + } +} + +extension Task { + + /// Asynchronously runs the given `operation` in its own task after the specified number of `seconds`. + /// + /// The operation will be executed after specified number of `seconds` passes. You can cancel the task earlier + /// for the operation to be skipped. + /// + /// - Parameters: + /// - time: Delay time in seconds. + /// - operation: The operation to execute. + /// - Returns: Handle to the task which can be cancelled. + @discardableResult + public static func delayed( + seconds: TimeInterval, + operation: @escaping @Sendable () async -> Void + ) -> Self where Success == Void, Failure == Never { + Self { + do { + try await Task<Never, Never>.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + await operation() + } catch {} + } + } +} diff --git a/damus/Util/NIP05.swift b/damus/Util/NIP05.swift @@ -39,11 +39,20 @@ enum NIP05Validation { case valid } -func validate_nip05(pubkey: String, nip05_str: String) async -> NIP05? { +struct FetchedNIP05 { + let response: NIP05Response + let nip05: NIP05Response +} + +func fetch_nip05_str(nip05_str: String) async -> NIP05Response? { guard let nip05 = NIP05.parse(nip05_str) else { return nil } + return await fetch_nip05(nip05: nip05) +} + +func fetch_nip05(nip05: NIP05) async -> NIP05Response? { guard let url = nip05.url else { return nil } @@ -57,6 +66,18 @@ func validate_nip05(pubkey: String, nip05_str: String) async -> NIP05? { return nil } + return decoded +} + +func validate_nip05(pubkey: String, nip05_str: String) async -> NIP05? { + guard let nip05 = NIP05.parse(nip05_str) else { + return nil + } + + guard let decoded = await fetch_nip05(nip05: nip05) else { + return nil + } + guard let stored_pk = decoded.names[nip05.username] else { return nil } diff --git a/damus/Views/Search/SearchingEventView.swift b/damus/Views/Search/SearchingEventView.swift @@ -17,12 +17,14 @@ enum SearchState { enum SearchType { case event case profile + case nip05 } struct SearchingEventView: View { let state: DamusState let evid: String let search_type: SearchType + @State var search_state: SearchState = .searching var bech32_evid: String { @@ -35,6 +37,8 @@ struct SearchingEventView: View { var search_name: String { switch search_type { + case .nip05: + return "nip05" case .profile: return "profile" case .event: @@ -67,9 +71,39 @@ struct SearchingEventView: View { Text("\(search_name.capitalized) not found", comment: "When a note or profile is not found when searching for it via its note id") } } - .onAppear { + .onChange(of: evid, debounceTime: 0.5) { evid in + self.search_state = .searching switch search_type { + case .nip05: + if let pk = state.profiles.nip05_pubkey[evid] { + if state.profiles.lookup(id: pk) != nil { + self.search_state = .found_profile(pk) + } + } else { + Task.init { + guard let nip05 = NIP05.parse(evid) else { + self.search_state = .not_found + return + } + guard let nip05_resp = await fetch_nip05(nip05: nip05) else { + DispatchQueue.main.async { + self.search_state = .not_found + } + return + } + + DispatchQueue.main.async { + guard let pk = nip05_resp.names[nip05.username] else { + self.search_state = .not_found + return + } + + self.search_state = .found_profile(pk) + } + } + } + case .event: if let ev = state.events.lookup(evid) { self.search_state = .found(ev) diff --git a/damus/Views/SearchResultsView.swift b/damus/Views/SearchResultsView.swift @@ -12,6 +12,7 @@ enum Search { case hashtag(String) case profile(String) case note(String) + case nip05(String) case hex(String) } @@ -41,6 +42,10 @@ struct SearchResultsView: View { NavigationLink(destination: dst) { Text("Search hashtag: #\(ht)", comment: "Navigation link to search hashtag.") } + + case .nip05(let addr): + SearchingEventView(state: damus_state, evid: addr, search_type: .nip05) + case .profile(let prof): let decoded = try? bech32_decode(prof) let hex = hex_encode(decoded!.data) @@ -95,6 +100,12 @@ func search_for_string(profiles: Profiles, _ new: String) -> Search? { return nil } + let splitted = new.split(separator: "@") + + if splitted.count == 2 { + return .nip05(new) + } + if new.first! == "#" { let ht = String(new.dropFirst().filter{$0 != " "}) return .hashtag(ht)