refactor: Adding structure
Huge refactor to add better structure to the project. Separating features with their associated view and model structure. This should be better organization and will allow us to improve the overall architecture in the future. I forsee many more improvements that can follow this change. e.g. MVVM Arch As well as cleaning up duplicate, unused, functionality. Many files have global functions that can also be moved or be renamed. damus/ ├── Features/ │ ├── <Feature>/ │ │ ├── Views/ │ │ └── Models/ ├── Shared/ │ ├── Components/ │ ├── Media/ │ ├── Buttons/ │ ├── Extensions/ │ ├── Empty Views/ │ ├── ErrorHandling/ │ ├── Modifiers/ │ └── Utilities/ ├── Core/ │ ├── Nostr/ │ ├── NIPs/ │ ├── DIPs/ │ ├── Types/ │ ├── Networking/ │ └── Storage/ Signed-off-by: ericholguin <ericholguin@apache.org>
This commit is contained in:
committed by
Daniel D’Aquino
parent
fdbf271432
commit
65a22813a3
@@ -0,0 +1,178 @@
|
||||
// SearchHomeModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-06-06.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
/// The data model for the SearchHome view, typically something global-like
|
||||
class SearchHomeModel: ObservableObject {
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
|
||||
var seen_pubkey: Set<Pubkey> = Set()
|
||||
let damus_state: DamusState
|
||||
let base_subid = UUID().description
|
||||
let follow_pack_subid = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
//let multiple_events_per_pubkey: Bool = false
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: damus_state, events: [ev])
|
||||
})
|
||||
}
|
||||
|
||||
func get_base_filter() -> NostrFilter {
|
||||
var filter = NostrFilter(kinds: [.text, .chat])
|
||||
filter.limit = self.limit
|
||||
filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||
return filter
|
||||
}
|
||||
|
||||
func filter_muted() {
|
||||
events.filter { should_show_event(state: damus_state, ev: $0) }
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
loading = true
|
||||
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||
|
||||
var follow_list_filter = NostrFilter(kinds: [.follow_list])
|
||||
follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: follow_pack_subid, filters: [follow_list_filter], handler: handle_event, to: to_relays)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: follow_pack_subid, to: to.map { [$0] })
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let event) = conn_ev else {
|
||||
return
|
||||
}
|
||||
|
||||
switch event {
|
||||
case .event(let sub_id, let ev):
|
||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||
{
|
||||
if !damus_state.settings.multiple_events_per_pubkey && seen_pubkey.contains(ev.pubkey) {
|
||||
return
|
||||
}
|
||||
seen_pubkey.insert(ev.pubkey)
|
||||
|
||||
if self.events.insert(ev) {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
case .notice(let msg):
|
||||
print("search home notice: \(msg)")
|
||||
case .ok:
|
||||
break
|
||||
case .eose(let sub_id):
|
||||
loading = false
|
||||
|
||||
if sub_id == self.base_subid {
|
||||
// Make sure we unsubscribe after we've fetched the global events
|
||||
// global events are not realtime
|
||||
unsubscribe(to: relay_id)
|
||||
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
load_profiles(context: "universe", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.all_events), damus_state: damus_state, txn: txn)
|
||||
}
|
||||
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func find_profiles_to_fetch<Y>(profiles: Profiles, load: PubkeysToLoad, cache: EventCache, txn: NdbTxn<Y>) -> [Pubkey] {
|
||||
switch load {
|
||||
case .from_events(let events):
|
||||
return find_profiles_to_fetch_from_events(profiles: profiles, events: events, cache: cache, txn: txn)
|
||||
case .from_keys(let pks):
|
||||
return find_profiles_to_fetch_from_keys(profiles: profiles, pks: pks, txn: txn)
|
||||
}
|
||||
}
|
||||
|
||||
func find_profiles_to_fetch_from_keys<Y>(profiles: Profiles, pks: [Pubkey], txn: NdbTxn<Y>) -> [Pubkey] {
|
||||
Array(Set(pks.filter { pk in !profiles.has_fresh_profile(id: pk, txn: txn) }))
|
||||
}
|
||||
|
||||
func find_profiles_to_fetch_from_events<Y>(profiles: Profiles, events: [NostrEvent], cache: EventCache, txn: NdbTxn<Y>) -> [Pubkey] {
|
||||
var pubkeys = Set<Pubkey>()
|
||||
|
||||
for ev in events {
|
||||
// lookup profiles from boosted events
|
||||
if ev.known_kind == .boost, let bev = ev.get_inner_event(cache: cache), !profiles.has_fresh_profile(id: bev.pubkey, txn: txn) {
|
||||
pubkeys.insert(bev.pubkey)
|
||||
}
|
||||
|
||||
if !profiles.has_fresh_profile(id: ev.pubkey, txn: txn) {
|
||||
pubkeys.insert(ev.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
return Array(pubkeys)
|
||||
}
|
||||
|
||||
enum PubkeysToLoad {
|
||||
case from_events([NostrEvent])
|
||||
case from_keys([Pubkey])
|
||||
}
|
||||
|
||||
func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayURL, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn<Y>) {
|
||||
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events, txn: txn)
|
||||
|
||||
guard !authors.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
print("load_profiles[\(context)]: requesting \(authors.count) profiles from \(relay_id)")
|
||||
|
||||
let filter = NostrFilter(kinds: [.metadata], authors: authors)
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
|
||||
|
||||
let now = UInt64(Date.now.timeIntervalSince1970)
|
||||
switch conn_ev {
|
||||
case .ws_event:
|
||||
break
|
||||
case .nostr_event(let ev):
|
||||
guard ev.subid == profiles_subid, rid == relay_id else { return }
|
||||
|
||||
switch ev {
|
||||
case .event(_, let ev):
|
||||
if ev.known_kind == .metadata {
|
||||
damus_state.ndb.write_profile_last_fetched(pubkey: ev.pubkey, fetched_at: now)
|
||||
}
|
||||
case .eose:
|
||||
print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)")
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
|
||||
case .ok:
|
||||
break
|
||||
case .notice:
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// Timeline.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-05-09.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
class SearchModel: ObservableObject {
|
||||
let state: DamusState
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
|
||||
var search: NostrFilter
|
||||
let sub_id = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
|
||||
init(state: DamusState, search: NostrFilter) {
|
||||
self.state = state
|
||||
self.search = search
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: state, events: [ev])
|
||||
})
|
||||
}
|
||||
|
||||
func filter_muted() {
|
||||
self.events.filter {
|
||||
should_show_event(state: state, ev: $0)
|
||||
}
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
// since 1 month
|
||||
search.limit = self.limit
|
||||
search.kinds = [.text, .like, .longform, .highlight, .follow_list]
|
||||
|
||||
//likes_filter.ids = ref_events.referenced_ids!
|
||||
|
||||
print("subscribing to search '\(search)' with sub_id \(sub_id)")
|
||||
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
loading = true
|
||||
state.nostrNetwork.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
loading = false
|
||||
print("unsubscribing from search '\(search)' with sub_id \(sub_id)")
|
||||
}
|
||||
|
||||
func add_event(_ ev: NostrEvent) {
|
||||
if !event_matches_filter(ev, filter: search) {
|
||||
return
|
||||
}
|
||||
|
||||
guard should_show_event(state: state, ev: ev) else {
|
||||
return
|
||||
}
|
||||
|
||||
if self.events.insert(ev) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
if ev.is_textlike && ev.should_show_event {
|
||||
self.add_event(ev)
|
||||
}
|
||||
}
|
||||
|
||||
guard done else {
|
||||
return
|
||||
}
|
||||
|
||||
self.loading = false
|
||||
|
||||
if sub_id == self.sub_id {
|
||||
guard let txn = NdbTxn(ndb: state.ndb) else { return }
|
||||
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func event_matches_hashtag(_ ev: NostrEvent, hashtags: [String]) -> Bool {
|
||||
for tag in ev.tags {
|
||||
if tag_is_hashtag(tag) && hashtags.contains(tag[1].string()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tag_is_hashtag(_ tag: Tag) -> Bool {
|
||||
// "hashtag" is deprecated, will remove in the future
|
||||
return tag.count >= 2 && tag[0].matches_char("t")
|
||||
}
|
||||
|
||||
func event_matches_filter(_ ev: NostrEvent, filter: NostrFilter) -> Bool {
|
||||
if let hashtags = filter.hashtag {
|
||||
return event_matches_hashtag(ev, hashtags: hashtags)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func handle_subid_event(pool: RelayPool, relay_id: RelayURL, ev: NostrConnectionEvent, handle: (String, NostrEvent) -> ()) -> (String?, Bool) {
|
||||
switch ev {
|
||||
case .ws_event:
|
||||
return (nil, false)
|
||||
|
||||
case .nostr_event(let res):
|
||||
switch res {
|
||||
case .event(let ev_subid, let ev):
|
||||
handle(ev_subid, ev)
|
||||
return (ev_subid, false)
|
||||
|
||||
case .ok:
|
||||
return (nil, false)
|
||||
|
||||
case .notice(let note):
|
||||
if note.contains("Too many subscription filters") {
|
||||
// TODO: resend filters?
|
||||
pool.reconnect(to: [relay_id])
|
||||
}
|
||||
return (nil, false)
|
||||
|
||||
case .eose(let subid):
|
||||
return (subid, true)
|
||||
|
||||
case .auth:
|
||||
return (nil, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// NDBSearchView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 9/9/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NDBSearchView: View {
|
||||
|
||||
let damus_state: DamusState
|
||||
@Binding var results: [NostrEvent]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if results.count > 0 {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image("search")
|
||||
Text("Top hits", comment: "A label indicating that the notes being displayed below it are all top note search results")
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
LazyVStack {
|
||||
ForEach(results, id: \.self) { note in
|
||||
EventView(damus: damus_state, event: note, options: [.truncate_content])
|
||||
.onTapGesture {
|
||||
let event = note.get_inner_event(cache: damus_state.events) ?? note
|
||||
let thread = ThreadModel(event: event, damus_state: damus_state)
|
||||
damus_state.nav.push(route: Route.Thread(thread: thread))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
ThiccDivider()
|
||||
}
|
||||
}
|
||||
|
||||
} else if results.count == 0 {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image("search")
|
||||
Text("No results", comment: "A label indicating that note search resulted in no results")
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// PullDownSearch.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-12-03.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PullDownSearchView: View {
|
||||
@State private var search_text = ""
|
||||
@State private var results: [NostrEvent] = []
|
||||
@State private var is_active: Bool = false
|
||||
let debouncer: Debouncer = Debouncer(interval: 0.25)
|
||||
let state: DamusState
|
||||
let on_cancel: () -> Void
|
||||
|
||||
func do_search(query: String) {
|
||||
let limit = 128
|
||||
let note_keys = state.ndb.text_search(query: query, limit: limit, order: .newest_first)
|
||||
var res = [NostrEvent]()
|
||||
// TODO: fix duplicate results from search
|
||||
var keyset = Set<NoteKey>()
|
||||
|
||||
// try reverse because newest first is a bit buggy on partial searches
|
||||
if note_keys.count == 0 {
|
||||
// don't touch existing results if there are no new ones
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let txn = NdbTxn(ndb: state.ndb) else { return }
|
||||
for note_key in note_keys {
|
||||
guard let note = state.ndb.lookup_note_by_key_with_txn(note_key, txn: txn) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if !keyset.contains(note_key) {
|
||||
let owned_note = note.to_owned()
|
||||
res.append(owned_note)
|
||||
keyset.insert(note_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let res_ = res
|
||||
|
||||
Task { @MainActor [res_] in
|
||||
results = res_
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
TextField(NSLocalizedString("Search", comment: "Title of the text field for searching."), text: $search_text)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onChange(of: search_text) { query in
|
||||
debouncer.debounce {
|
||||
Task.detached {
|
||||
do_search(query: query)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
is_active = true
|
||||
}
|
||||
|
||||
if is_active {
|
||||
Button(action: {
|
||||
search_text = ""
|
||||
results = []
|
||||
end_editing()
|
||||
on_cancel()
|
||||
}, label: {
|
||||
Text("Cancel", comment: "Button to cancel out of search text entry mode.")
|
||||
})
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
if results.count > 0 {
|
||||
HStack {
|
||||
Image("search")
|
||||
Text("Top hits", comment: "A label indicating that the notes being displayed below it are all top note search results")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
ForEach(results, id: \.self) { note in
|
||||
EventView(damus: state, event: note)
|
||||
.onTapGesture {
|
||||
let event = note.get_inner_event(cache: state.events) ?? note
|
||||
let thread = ThreadModel(event: event, damus_state: state)
|
||||
state.nav.push(route: Route.Thread(thread: thread))
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image("notes.fill")
|
||||
Text("Notes", comment: "A label indicating that the notes being displayed below it are from a timeline, not search results")
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal)
|
||||
} else if results.count == 0 && !search_text.isEmpty {
|
||||
HStack {
|
||||
Image("search")
|
||||
Text("No results", comment: "A label indicating that note search resulted in no results")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PullDownSearchView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PullDownSearchView(state: test_damus_state, on_cancel: {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// SearchIconView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-07-12.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SearchHeaderView: View {
|
||||
let state: DamusState
|
||||
let described: DescribedSearch
|
||||
@State var is_following: Bool
|
||||
|
||||
init(state: DamusState, described: DescribedSearch) {
|
||||
self.state = state
|
||||
self.described = described
|
||||
|
||||
let is_following = (described.is_hashtag.map {
|
||||
ht in is_following_hashtag(contacts: state.contacts.event, hashtag: ht)
|
||||
}) ?? false
|
||||
|
||||
self._is_following = State(wrappedValue: is_following)
|
||||
}
|
||||
|
||||
var Icon: some View {
|
||||
ZStack {
|
||||
switch described {
|
||||
case .hashtag:
|
||||
SingleCharacterAvatar(character: "#")
|
||||
case .unknown:
|
||||
SystemIconAvatar(system_name: "magnifyingglass")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var SearchText: Text {
|
||||
Text(described.description)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 30) {
|
||||
Icon
|
||||
|
||||
VStack(alignment: .leading, spacing: 10.0) {
|
||||
SearchText
|
||||
.foregroundStyle(DamusLogoGradient.gradient)
|
||||
.font(.title.bold())
|
||||
|
||||
if state.is_privkey_user, case .hashtag(let ht) = described {
|
||||
if is_following {
|
||||
HashtagUnfollowButton(damus_state: state, hashtag: ht, is_following: $is_following)
|
||||
} else {
|
||||
HashtagFollowButton(damus_state: state, hashtag: ht, is_following: $is_following)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.followed)) { ref in
|
||||
guard hashtag_matches_search(desc: self.described, ref: ref) else { return }
|
||||
self.is_following = true
|
||||
}
|
||||
.onReceive(handle_notify(.unfollowed)) { ref in
|
||||
guard hashtag_matches_search(desc: self.described, ref: ref) else { return }
|
||||
self.is_following = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SystemIconAvatar: View {
|
||||
let system_name: String
|
||||
|
||||
var body: some View {
|
||||
NonImageAvatar {
|
||||
Image(systemName: system_name)
|
||||
.font(.title.bold())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SingleCharacterAvatar: View {
|
||||
let character: String
|
||||
|
||||
var body: some View {
|
||||
NonImageAvatar {
|
||||
Text(character)
|
||||
.font(.largeTitle.bold())
|
||||
.mask(Text(character)
|
||||
.font(.largeTitle.bold()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NonImageAvatar<Content: View>: View {
|
||||
let content: Content
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(DamusColors.lightBackgroundPink)
|
||||
.frame(width: 54, height: 54)
|
||||
|
||||
content
|
||||
.foregroundStyle(PinkGradient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HashtagUnfollowButton: View {
|
||||
let damus_state: DamusState
|
||||
let hashtag: String
|
||||
@Binding var is_following: Bool
|
||||
|
||||
var body: some View {
|
||||
return Button(action: { unfollow(hashtag) }) {
|
||||
Text("Unfollow hashtag", comment: "Button to unfollow a given hashtag.")
|
||||
.font(.footnote.bold())
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle(padding: 10))
|
||||
}
|
||||
|
||||
func unfollow(_ hashtag: String) {
|
||||
is_following = false
|
||||
handle_unfollow(state: damus_state, unfollow: FollowRef.hashtag(hashtag))
|
||||
}
|
||||
}
|
||||
|
||||
struct HashtagFollowButton: View {
|
||||
let damus_state: DamusState
|
||||
let hashtag: String
|
||||
@Binding var is_following: Bool
|
||||
|
||||
var body: some View {
|
||||
return Button(action: { follow(hashtag) }) {
|
||||
Text("Follow hashtag", comment: "Button to follow a given hashtag.")
|
||||
.font(.footnote.bold())
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle(padding: 10))
|
||||
}
|
||||
|
||||
func follow(_ hashtag: String) {
|
||||
is_following = true
|
||||
handle_follow(state: damus_state, follow: .hashtag(hashtag))
|
||||
}
|
||||
}
|
||||
|
||||
func hashtag_matches_search(desc: DescribedSearch, ref: FollowRef) -> Bool {
|
||||
guard case .hashtag(let follow_ht) = ref,
|
||||
case .hashtag(let search_ht) = desc,
|
||||
follow_ht == search_ht
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func is_following_hashtag(contacts: NostrEvent?, hashtag: String) -> Bool {
|
||||
guard let contacts else { return false }
|
||||
return is_already_following(contacts: contacts, follow: .hashtag(hashtag))
|
||||
}
|
||||
|
||||
|
||||
struct SearchHeaderView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading) {
|
||||
SearchHeaderView(state: test_damus_state, described: .hashtag("damus"))
|
||||
|
||||
SearchHeaderView(state: test_damus_state, described: .unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//
|
||||
// SearchHomeView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-05-19.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
import NaturalLanguage
|
||||
|
||||
struct SearchHomeView: View {
|
||||
let damus_state: DamusState
|
||||
@StateObject var model: SearchHomeModel
|
||||
@State var search: String = ""
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: damus_state)
|
||||
filters.append(fstate.filter)
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
|
||||
var SearchInput: some View {
|
||||
HStack {
|
||||
HStack{
|
||||
Image("search")
|
||||
.foregroundColor(.gray)
|
||||
TextField(NSLocalizedString("Search...", comment: "Placeholder text to prompt entry of search query."), text: $search)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($isFocused)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.secondary.opacity(0.2))
|
||||
.cornerRadius(20)
|
||||
|
||||
if(!search.isEmpty) {
|
||||
Text("Cancel", comment: "Cancel out of search view.")
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(EdgeInsets(top: 0.0, leading: 0.0, bottom: 0.0, trailing: 10.0))
|
||||
.onTapGesture {
|
||||
self.search = ""
|
||||
isFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var GlobalContent: some View {
|
||||
return TimelineView<AnyView>(
|
||||
events: model.events,
|
||||
loading: $model.loading,
|
||||
damus: damus_state,
|
||||
show_friend_icon: true,
|
||||
filter:content_filter(FilterState.posts),
|
||||
content: {
|
||||
AnyView(VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(PinkGradient)
|
||||
Text("Follow Packs", comment: "A label indicating that the items below it are follow packs")
|
||||
.foregroundStyle(PinkGradient)
|
||||
}
|
||||
.padding(.top)
|
||||
.padding(.horizontal)
|
||||
|
||||
FollowPackTimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true,filter:content_filter(FilterState.follow_list)
|
||||
).padding(.bottom)
|
||||
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
|
||||
HStack {
|
||||
Image("notes.fill")
|
||||
Text("All recent notes", comment: "A label indicating that the notes being displayed below it are all recent notes")
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal)
|
||||
}.padding(.bottom, 50))
|
||||
}
|
||||
)
|
||||
.refreshable {
|
||||
// Fetch new information by unsubscribing and resubscribing to the relay
|
||||
model.unsubscribe()
|
||||
model.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
var SearchContent: some View {
|
||||
SearchResultsView(damus_state: damus_state, search: $search)
|
||||
.refreshable {
|
||||
// Fetch new information by unsubscribing and resubscribing to the relay
|
||||
model.unsubscribe()
|
||||
model.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
var MainContent: some View {
|
||||
Group {
|
||||
if search.isEmpty {
|
||||
GlobalContent
|
||||
} else {
|
||||
SearchContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
MainContent
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
SearchInput
|
||||
//.frame(maxWidth: 275)
|
||||
.padding()
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
.onReceive(handle_notify(.new_mutes)) { _ in
|
||||
self.model.filter_muted()
|
||||
}
|
||||
.onAppear {
|
||||
if model.events.events.isEmpty {
|
||||
model.subscribe()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
model.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchHomeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let state = test_damus_state
|
||||
SearchHomeView(damus_state: state, model: SearchHomeModel(damus_state: state))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//
|
||||
// SearchResultsView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-06-06.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MultiSearch {
|
||||
let text: String
|
||||
let hashtag: String
|
||||
let profiles: [Pubkey]
|
||||
}
|
||||
|
||||
enum Search: Identifiable {
|
||||
case profiles([Pubkey])
|
||||
case hashtag(String)
|
||||
case profile(Pubkey)
|
||||
case note(NoteId)
|
||||
case nip05(String)
|
||||
case hex(Data)
|
||||
case multi(MultiSearch)
|
||||
case nevent(NEvent)
|
||||
case naddr(NAddr)
|
||||
case nprofile(NProfile)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .profiles: return "profiles"
|
||||
case .hashtag: return "hashtag"
|
||||
case .profile: return "profile"
|
||||
case .note: return "note"
|
||||
case .nip05: return "nip05"
|
||||
case .hex: return "hex"
|
||||
case .multi: return "multi"
|
||||
case .nevent: return "nevent"
|
||||
case .naddr: return "naddr"
|
||||
case .nprofile: return "nprofile"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InnerSearchResults: View {
|
||||
let damus_state: DamusState
|
||||
let search: Search?
|
||||
@Binding var results: [NostrEvent]
|
||||
|
||||
func ProfileSearchResult(pk: Pubkey) -> some View {
|
||||
FollowUserView(target: .pubkey(pk), damus_state: damus_state)
|
||||
}
|
||||
|
||||
func HashtagSearch(_ ht: String) -> some View {
|
||||
let search_model = SearchModel(state: damus_state, search: .filter_hashtag([ht]))
|
||||
return NavigationLink(value: Route.Search(search: search_model)) {
|
||||
HStack {
|
||||
Text("#\(ht)", comment: "Navigation link to search hashtag.")
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 5)
|
||||
.background(DamusColors.neutral1)
|
||||
.cornerRadius(20)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TextSearch(_ txt: String) -> some View {
|
||||
return NavigationLink(value: Route.NDBSearch(results: $results)) {
|
||||
HStack {
|
||||
Text("Search word: \(txt)", comment: "Navigation link to search for a word.")
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 5)
|
||||
.background(DamusColors.neutral1)
|
||||
.cornerRadius(20)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func ProfilesSearch(_ results: [Pubkey]) -> some View {
|
||||
return LazyVStack {
|
||||
ForEach(results, id: \.id) { pk in
|
||||
ProfileSearchResult(pk: pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch search {
|
||||
case .profiles(let results):
|
||||
ProfilesSearch(results)
|
||||
case .hashtag(let ht):
|
||||
HashtagSearch(ht)
|
||||
case .nip05(let addr):
|
||||
SearchingEventView(state: damus_state, search_type: .nip05(addr))
|
||||
case .profile(let pubkey):
|
||||
SearchingEventView(state: damus_state, search_type: .profile(pubkey))
|
||||
case .hex(let h):
|
||||
VStack(spacing: 10) {
|
||||
SearchingEventView(state: damus_state, search_type: .event(NoteId(h)))
|
||||
SearchingEventView(state: damus_state, search_type: .profile(Pubkey(h)))
|
||||
}
|
||||
case .note(let nid):
|
||||
SearchingEventView(state: damus_state, search_type: .event(nid))
|
||||
case .nevent(let nevent):
|
||||
SearchingEventView(state: damus_state, search_type: .event(nevent.noteid))
|
||||
case .nprofile(let nprofile):
|
||||
SearchingEventView(state: damus_state, search_type: .profile(nprofile.author))
|
||||
case .naddr(let naddr):
|
||||
SearchingEventView(state: damus_state, search_type: .naddr(naddr))
|
||||
case .multi(let multi):
|
||||
VStack(alignment: .leading) {
|
||||
HStack(spacing: 20) {
|
||||
HashtagSearch(multi.hashtag)
|
||||
TextSearch(multi.text)
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
|
||||
ProfilesSearch(multi.profiles)
|
||||
}
|
||||
|
||||
case .none:
|
||||
Text("none", comment: "No search results.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResultsView: View {
|
||||
let damus_state: DamusState
|
||||
@Binding var search: String
|
||||
@State var result: Search? = nil
|
||||
@State var results: [NostrEvent] = []
|
||||
let debouncer: Debouncer = Debouncer(interval: 0.25)
|
||||
|
||||
func do_search(query: String) {
|
||||
let limit = 128
|
||||
var note_keys = damus_state.ndb.text_search(query: query, limit: limit, order: .newest_first)
|
||||
var res = [NostrEvent]()
|
||||
// TODO: fix duplicate results from search
|
||||
var keyset = Set<NoteKey>()
|
||||
|
||||
// try reverse because newest first is a bit buggy on partial searches
|
||||
if note_keys.count == 0 {
|
||||
// don't touch existing results if there are no new ones
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
for note_key in note_keys {
|
||||
guard let note = damus_state.ndb.lookup_note_by_key_with_txn(note_key, txn: txn) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if !keyset.contains(note_key) {
|
||||
let owned_note = note.to_owned()
|
||||
res.append(owned_note)
|
||||
keyset.insert(note_key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let res_ = res
|
||||
|
||||
Task { @MainActor [res_] in
|
||||
results = res_
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
InnerSearchResults(damus_state: damus_state, search: result, results: $results)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.onAppear {
|
||||
guard let txn = NdbTxn.init(ndb: damus_state.ndb) else { return }
|
||||
self.result = search_for_string(profiles: damus_state.profiles, contacts: damus_state.contacts, search: search, txn: txn)
|
||||
}
|
||||
.onChange(of: search) { new in
|
||||
guard let txn = NdbTxn.init(ndb: damus_state.ndb) else { return }
|
||||
self.result = search_for_string(profiles: damus_state.profiles, contacts: damus_state.contacts, search: search, txn: txn)
|
||||
}
|
||||
.onChange(of: search) { query in
|
||||
debouncer.debounce {
|
||||
Task.detached {
|
||||
do_search(query: query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
struct SearchResultsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SearchResultsView(damus_state: test_damus_state(), s)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
func search_for_string<Y>(profiles: Profiles, contacts: Contacts, search new: String, txn: NdbTxn<Y>) -> Search? {
|
||||
guard new.count != 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let splitted = new.split(separator: "@")
|
||||
|
||||
if splitted.count == 2 {
|
||||
return .nip05(new)
|
||||
}
|
||||
|
||||
if new.first! == "#" {
|
||||
return .hashtag(make_hashtagable(new))
|
||||
}
|
||||
|
||||
let searchQuery = remove_nostr_uri_prefix(new)
|
||||
|
||||
if let new = hex_decode_id(searchQuery) {
|
||||
return .hex(new)
|
||||
}
|
||||
|
||||
if searchQuery.starts(with: "npub") {
|
||||
if let decoded = bech32_pubkey_decode(searchQuery) {
|
||||
return .profile(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
if searchQuery.starts(with: "note"), let decoded = try? bech32_decode(searchQuery) {
|
||||
return .note(NoteId(decoded.data))
|
||||
}
|
||||
|
||||
if searchQuery.starts(with: "nevent"), case let .nevent(nevent) = Bech32Object.parse(searchQuery) {
|
||||
return .nevent(nevent)
|
||||
}
|
||||
|
||||
if searchQuery.starts(with: "nprofile"), case let .nprofile(nprofile) = Bech32Object.parse(searchQuery) {
|
||||
return .nprofile(nprofile)
|
||||
}
|
||||
|
||||
if searchQuery.starts(with: "naddr"), case let .naddr(naddr) = Bech32Object.parse(searchQuery) {
|
||||
return .naddr(naddr)
|
||||
}
|
||||
|
||||
let multisearch = MultiSearch(text: new, hashtag: make_hashtagable(searchQuery), profiles: search_profiles(profiles: profiles, contacts: contacts, search: new, txn: txn))
|
||||
return .multi(multisearch)
|
||||
}
|
||||
|
||||
func make_hashtagable(_ str: String) -> String {
|
||||
var new = str
|
||||
guard str.utf8.count > 0 else {
|
||||
return str
|
||||
}
|
||||
|
||||
if new.hasPrefix("#") {
|
||||
new = String(new.dropFirst())
|
||||
}
|
||||
|
||||
return String(new.filter{$0 != " "})
|
||||
}
|
||||
|
||||
func search_profiles<Y>(profiles: Profiles, contacts: Contacts, search: String, txn: NdbTxn<Y>) -> [Pubkey] {
|
||||
// Search by hex pubkey.
|
||||
if let pubkey = hex_decode_pubkey(search),
|
||||
profiles.lookup_key_by_pubkey(pubkey) != nil
|
||||
{
|
||||
return [pubkey]
|
||||
}
|
||||
|
||||
// Search by npub pubkey.
|
||||
if search.starts(with: "npub"),
|
||||
let bech32_key = decode_bech32_key(search),
|
||||
case Bech32Key.pub(let pk) = bech32_key,
|
||||
profiles.lookup_key_by_pubkey(pk) != nil
|
||||
{
|
||||
return [pk]
|
||||
}
|
||||
|
||||
return profiles.search(search, limit: 128, txn: txn).sorted { a, b in
|
||||
let aFriendTypePriority = get_friend_type(contacts: contacts, pubkey: a)?.priority ?? 0
|
||||
let bFriendTypePriority = get_friend_type(contacts: contacts, pubkey: b)?.priority ?? 0
|
||||
|
||||
if aFriendTypePriority > bFriendTypePriority {
|
||||
// `a` should be sorted before `b`
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// SearchView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-05-09.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SearchView: View {
|
||||
let appstate: DamusState
|
||||
@ObservedObject var search: SearchModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var is_hashtag_muted: Bool = false
|
||||
|
||||
var content_filter: (NostrEvent) -> Bool {
|
||||
let filters = ContentFilters.defaults(damus_state: self.appstate)
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
|
||||
let height: CGFloat = 250.0
|
||||
|
||||
var body: some View {
|
||||
TimelineView(events: search.events, loading: $search.loading, damus: appstate, show_friend_icon: true, filter: content_filter) {
|
||||
ZStack(alignment: .leading) {
|
||||
DamusBackground(maxHeight: height)
|
||||
.mask(LinearGradient(gradient: Gradient(colors: [.black, .black, .black, .clear]), startPoint: .top, endPoint: .bottom))
|
||||
SearchHeaderView(state: appstate, described: described_search)
|
||||
.padding(.leading, 30)
|
||||
.padding(.top, 100)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onReceive(handle_notify(.switched_timeline)) { obj in
|
||||
dismiss()
|
||||
}
|
||||
.onAppear() {
|
||||
search.subscribe()
|
||||
}
|
||||
.onDisappear() {
|
||||
search.unsubscribe()
|
||||
}
|
||||
.onReceive(handle_notify(.new_mutes)) { notif in
|
||||
search.filter_muted()
|
||||
|
||||
if let hashtag_string = search.search.hashtag?.first,
|
||||
notif.contains(MuteItem.hashtag(Hashtag(hashtag: hashtag_string), nil)) {
|
||||
is_hashtag_muted = true
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.new_unmutes)) { unmutes in
|
||||
if let hashtag_string = search.search.hashtag?.first,
|
||||
unmutes.contains(MuteItem.hashtag(Hashtag(hashtag: hashtag_string), nil)) {
|
||||
is_hashtag_muted = false
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if let hashtag = search.search.hashtag?.first {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
if is_hashtag_muted {
|
||||
Button {
|
||||
guard
|
||||
let full_keypair = appstate.keypair.to_full(),
|
||||
let existing_mutelist = appstate.mutelist_manager.event,
|
||||
let mutelist = remove_from_mutelist(keypair: full_keypair, prev: existing_mutelist, to_remove: .hashtag(Hashtag(hashtag: hashtag), nil))
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
appstate.mutelist_manager.set_mutelist(mutelist)
|
||||
appstate.nostrNetwork.postbox.send(mutelist)
|
||||
} label: {
|
||||
Text("Unmute Hashtag", comment: "Label represnting a button that the user can tap to unmute a given hashtag so they start seeing it in their feed again.")
|
||||
}
|
||||
} else {
|
||||
MuteDurationMenu { duration in
|
||||
mute_hashtag(hashtag_string: hashtag, expiration_time: duration?.date_from_now)
|
||||
} label: {
|
||||
Text("Mute Hashtag", comment: "Label represnting a button that the user can tap to mute a given hashtag so they don't see it in their feed anymore.")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let hashtag_string = search.search.hashtag?.first {
|
||||
is_hashtag_muted = (appstate.mutelist_manager.event?.mute_list ?? []).contains(MuteItem.hashtag(Hashtag(hashtag: hashtag_string), nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mute_hashtag(hashtag_string: String, expiration_time: Date?) {
|
||||
let existing_mutelist = appstate.mutelist_manager.event
|
||||
|
||||
guard
|
||||
let full_keypair = appstate.keypair.to_full(),
|
||||
let mutelist = create_or_update_mutelist(keypair: full_keypair, mprev: existing_mutelist, to_add: .hashtag(Hashtag(hashtag: hashtag_string), expiration_time))
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
appstate.mutelist_manager.set_mutelist(mutelist)
|
||||
appstate.nostrNetwork.postbox.send(mutelist)
|
||||
}
|
||||
|
||||
var described_search: DescribedSearch {
|
||||
return describe_search(search.search)
|
||||
}
|
||||
}
|
||||
|
||||
enum DescribedSearch: CustomStringConvertible {
|
||||
case hashtag(String)
|
||||
case unknown
|
||||
|
||||
var is_hashtag: String? {
|
||||
switch self {
|
||||
case .hashtag(let ht):
|
||||
return ht
|
||||
case .unknown:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .hashtag(let s):
|
||||
return "#" + s
|
||||
case .unknown:
|
||||
return NSLocalizedString("Search", comment: "Default title for the search screen when it is in an unknown state.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func describe_search(_ filter: NostrFilter) -> DescribedSearch {
|
||||
if let hashtags = filter.hashtag {
|
||||
if hashtags.count >= 1 {
|
||||
return .hashtag(hashtags[0])
|
||||
}
|
||||
}
|
||||
|
||||
return .unknown
|
||||
}
|
||||
|
||||
struct SearchView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let test_state = test_damus_state
|
||||
let filter = NostrFilter(hashtag: ["bitcoin"])
|
||||
|
||||
let model = SearchModel(state: test_state, search: filter)
|
||||
|
||||
SearchView(appstate: test_state, search: model)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// SearchingEventView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-03-05.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum SearchState {
|
||||
case searching
|
||||
case found(NostrEvent)
|
||||
case found_profile(Pubkey)
|
||||
case not_found
|
||||
}
|
||||
|
||||
enum SearchType: Equatable {
|
||||
case event(NoteId)
|
||||
case profile(Pubkey)
|
||||
case nip05(String)
|
||||
case naddr(NAddr)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct SearchingEventView: View {
|
||||
let state: DamusState
|
||||
let search_type: SearchType
|
||||
|
||||
@State var search_state: SearchState = .searching
|
||||
|
||||
var search_name: String {
|
||||
switch search_type {
|
||||
case .nip05:
|
||||
return "Nostr Address"
|
||||
case .profile:
|
||||
return "Profile"
|
||||
case .event:
|
||||
return "Note"
|
||||
case .naddr:
|
||||
return "Naddr"
|
||||
}
|
||||
}
|
||||
|
||||
func handle_search(search: SearchType) {
|
||||
self.search_state = .searching
|
||||
|
||||
switch search {
|
||||
case .nip05(let nip05):
|
||||
if let pk = state.profiles.nip05_pubkey[nip05] {
|
||||
if state.profiles.lookup_key_by_pubkey(pk) != nil {
|
||||
self.search_state = .found_profile(pk)
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
guard let nip05 = NIP05.parse(nip05) else {
|
||||
Task { @MainActor in
|
||||
self.search_state = .not_found
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let nip05_resp = await fetch_nip05(nip05: nip05) else {
|
||||
Task { @MainActor in
|
||||
self.search_state = .not_found
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
guard let pk = nip05_resp.names[nip05.username] else {
|
||||
self.search_state = .not_found
|
||||
return
|
||||
}
|
||||
|
||||
self.search_state = .found_profile(pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .event(let note_id):
|
||||
find_event(state: state, query: .event(evid: note_id)) { res in
|
||||
guard case .event(let ev) = res else {
|
||||
self.search_state = .not_found
|
||||
return
|
||||
}
|
||||
self.search_state = .found(ev)
|
||||
}
|
||||
case .profile(let pubkey):
|
||||
find_event(state: state, query: .profile(pubkey: pubkey)) { res in
|
||||
guard case .profile(let pubkey) = res else {
|
||||
self.search_state = .not_found
|
||||
return
|
||||
}
|
||||
self.search_state = .found_profile(pubkey)
|
||||
}
|
||||
case .naddr(let naddr):
|
||||
naddrLookup(damus_state: state, naddr: naddr) { res in
|
||||
guard let res = res else {
|
||||
self.search_state = .not_found
|
||||
return
|
||||
}
|
||||
self.search_state = .found(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch search_state {
|
||||
case .searching:
|
||||
HStack(spacing: 10) {
|
||||
Text("Looking for \(search_name)...", comment: "Label that appears when searching for note or profile")
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
case .found(let ev):
|
||||
NavigationLink(value: Route.Thread(thread: ThreadModel(event: ev, damus_state: state))) {
|
||||
EventView(damus: state, event: ev)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
case .found_profile(let pk):
|
||||
NavigationLink(value: Route.ProfileByKey(pubkey: pk)) {
|
||||
FollowUserView(target: .pubkey(pk), damus_state: state)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
case .not_found:
|
||||
Text("\(search_name) not found", comment: "When a note or profile is not found when searching for it via its note id")
|
||||
}
|
||||
}
|
||||
.onChange(of: search_type, debounceTime: 0.5) { stype in
|
||||
handle_search(search: stype)
|
||||
}
|
||||
.onAppear {
|
||||
handle_search(search: search_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchingEventView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let state = test_damus_state
|
||||
SearchingEventView(state: state, search_type: .event(test_note.id))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user