commit 505ce0bd392a5f8a17f445b9fb7bd6571d44e102
parent 0fae54a98df16d7bb04751ee54ac052f2f6b6eb0
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 13 Jul 2023 11:10:53 -0700
Add the ability to follow hashtags
Changelog-Added: Add the ability to follow hashtags
Diffstat:
7 files changed, 407 insertions(+), 93 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -144,6 +144,7 @@
 		4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */; };
 		4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 4C649880286E0EE300EAE2B3 /* secp256k1 */; };
 		4C687C212A5F7ED00092C550 /* DamusBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C202A5F7ED00092C550 /* DamusBackground.swift */; };
+		4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */; };
 		4C687C272A6039500092C550 /* TestData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C262A6039500092C550 /* TestData.swift */; };
 		4C73C5142A4437C10062CAC0 /* ZapUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C73C5132A4437C10062CAC0 /* ZapUserView.swift */; };
 		4C75EFA427FA577B0006080F /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA327FA577B0006080F /* PostView.swift */; };
@@ -619,6 +620,7 @@
 		4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesView.swift; sourceTree = "<group>"; };
 		4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesModel.swift; sourceTree = "<group>"; };
 		4C687C202A5F7ED00092C550 /* DamusBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusBackground.swift; sourceTree = "<group>"; };
+		4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHeaderView.swift; sourceTree = "<group>"; };
 		4C687C262A6039500092C550 /* TestData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestData.swift; sourceTree = "<group>"; };
 		4C73C5132A4437C10062CAC0 /* ZapUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapUserView.swift; sourceTree = "<group>"; };
 		4C75EFA327FA577B0006080F /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = "<group>"; };
@@ -1099,6 +1101,14 @@
 			path = Notifications;
 			sourceTree = "<group>";
 		};
+		4C687C2A2A6058450092C550 /* Search */ = {
+			isa = PBXGroup;
+			children = (
+				4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */,
+			);
+			path = Search;
+			sourceTree = "<group>";
+		};
 		4C75EFA227FA576C0006080F /* Views */ = {
 			isa = PBXGroup;
 			children = (
@@ -1404,6 +1414,7 @@
 		4CE4F9DF285287A000C00DD9 /* Components */ = {
 			isa = PBXGroup;
 			children = (
+				4C687C2A2A6058450092C550 /* Search */,
 				4C7D09702A0AEF4C00943473 /* Gradients */,
 				31D2E846295218AF006D67F8 /* Shimmer.swift */,
 				4CD7641A28A1641400B6928F /* EndBlock.swift */,
@@ -1946,6 +1957,7 @@
 				3169CAE6294E69C000EE4006 /* EmptyTimelineView.swift in Sources */,
 				4CC7AAF0297F11C700430951 /* SelectedEventView.swift in Sources */,
 				4CC7AAF8297F1CEE00430951 /* EventProfile.swift in Sources */,
+				4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */,
 				64FBD06F296255C400D9D3B2 /* Theme.swift in Sources */,
 				4C1A9A2329DDDB8100516EAC /* IconLabel.swift in Sources */,
 				4C3EA64928FF597700C48A62 /* bech32.c in Sources */,
diff --git a/damus/Components/Search/SearchHeaderView.swift b/damus/Components/Search/SearchHeaderView.swift
@@ -0,0 +1,134 @@
+//
+//  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 {
+            Circle()
+                .fill(Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0))
+                .frame(width: 54, height: 54)
+
+            switch described {
+            case .hashtag:
+                Text("#")
+                    .font(.largeTitle.bold())
+                    .foregroundStyle(PinkGradient)
+                    .mask(Text("#")
+                        .font(.largeTitle.bold()))
+
+            case .unknown:
+                Image(systemName: "magnifyingglass")
+                    .font(.title.bold())
+                    .foregroundStyle(PinkGradient)
+            }
+        }
+    }
+
+    var SearchText: Text {
+        switch described {
+        case .hashtag(let ht):
+            Text(verbatim: "#" + ht)
+        case .unknown:
+            Text("Search")
+        }
+    }
+
+    func unfollow(_ hashtag: String) {
+        is_following = false
+        handle_unfollow(state: state, unfollow: .t(hashtag))
+    }
+
+    func follow(_ hashtag: String) {
+        is_following = true
+        handle_follow(state: state, follow: .t(hashtag))
+    }
+
+    func FollowButton(_ ht: String) -> some View {
+        return Button(action: { follow(ht) }) {
+            Text("Follow hashtag")
+                .font(.footnote.bold())
+        }
+        .buttonStyle(GradientButtonStyle(padding: 10))
+    }
+
+    func UnfollowButton(_ ht: String) -> some View {
+        return Button(action: { unfollow(ht) }) {
+            Text("Unfollow hashtag")
+                .font(.footnote.bold())
+        }
+        .buttonStyle(GradientButtonStyle(padding: 10))
+    }
+
+    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 {
+                        UnfollowButton(ht)
+                    } else {
+                        FollowButton(ht)
+                    }
+                }
+            }
+        }
+        .onReceive(handle_notify(.followed)) { notif in
+            let ref = notif.object as! ReferencedId
+            guard hashtag_matches_search(desc: self.described, ref: ref) else { return }
+            self.is_following = true
+        }
+        .onReceive(handle_notify(.unfollowed)) { notif in
+            let ref = notif.object as! ReferencedId
+            guard hashtag_matches_search(desc: self.described, ref: ref) else { return }
+            self.is_following = false
+        }
+    }
+}
+
+func hashtag_matches_search(desc: DescribedSearch, ref: ReferencedId) -> Bool {
+    guard let ht = desc.is_hashtag, ref.key == "t" && ref.ref_id == 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: .t(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)
+        }
+    }
+}
diff --git a/damus/ContentView.swift b/damus/ContentView.swift
@@ -395,16 +395,22 @@ struct ContentView: View {
             }
         }
         .onReceive(handle_notify(.unfollow)) { notif in
-            guard let state = self.damus_state else {
-                return
-            }
-            handle_unfollow(state: state, notif: notif)
+            guard let state = self.damus_state else { return }
+            guard let unfollow = handle_unfollow_notif(state: state, notif: notif) else { return }
+        }
+        .onReceive(handle_notify(.unfollowed)) { notif in
+            guard let state = self.damus_state else { return }
+            let unfollow = notif.object as! ReferencedId
+            home.resubscribe(.unfollowing(unfollow))
         }
         .onReceive(handle_notify(.follow)) { notif in
-            guard let state = self.damus_state else {
-                return
-            }
-            handle_follow(state: state, notif: notif)
+            guard let state = self.damus_state else { return }
+            guard handle_follow_notif(state: state, notif: notif) else { return }
+        }
+        .onReceive(handle_notify(.followed)) { notif in
+            guard let state = self.damus_state else { return }
+            let follow = notif.object as! ReferencedId
+            home.resubscribe(.following)
         }
         .onReceive(handle_notify(.post)) { notif in
             guard let state = self.damus_state,
@@ -879,47 +885,75 @@ func timeline_name(_ timeline: Timeline?) -> String {
     }
 }
 
-func handle_unfollow(state: DamusState, notif: Notification) {
+@discardableResult
+func handle_unfollow(state: DamusState, unfollow: ReferencedId) -> Bool {
     guard let keypair = state.keypair.to_full() else {
-        return
+        return false
     }
-    
-    let target = notif.object as! FollowTarget
-    let pk = target.pubkey
+
     let old_contacts = state.contacts.event
 
-    guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: .p(pk))
-    else { return }
+    guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
+    else {
+        return false
+    }
 
-    notify(.unfollowed, pk)
+    notify(.unfollowed, unfollow)
 
     state.contacts.event = ev
-    state.contacts.remove_friend(pk)
-    state.user_search_cache.updateOwnContactsPetnames(id: state.pubkey, oldEvent: old_contacts, newEvent: ev)
+
+    if unfollow.key == "p" {
+        state.contacts.remove_friend(unfollow.ref_id)
+        state.user_search_cache.updateOwnContactsPetnames(id: state.pubkey, oldEvent: old_contacts, newEvent: ev)
+    }
+
+    return true
 }
 
-func handle_follow(state: DamusState, notif: Notification) {
-    guard let keypair = state.keypair.to_full() else {
-        return
+func handle_unfollow_notif(state: DamusState, notif: Notification) -> ReferencedId? {
+    let target = notif.object as! FollowTarget
+    let pk = target.pubkey
+
+    let ref = ReferencedId.p(pk)
+    if handle_unfollow(state: state, unfollow: ref) {
+        return ref
     }
 
-    let fnotify = notif.object as! FollowTarget
+    return nil
+}
+
+@discardableResult
+func handle_follow(state: DamusState, follow: ReferencedId) -> Bool {
+    guard let keypair = state.keypair.to_full() else {
+        return false
+    }
 
-    guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: .p(fnotify.pubkey))
+    guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
     else {
-        return
+        return false
     }
 
-    notify(.followed, fnotify.pubkey)
+    notify(.followed, follow)
 
     state.contacts.event = ev
+    if follow.key == "p" {
+        state.contacts.add_friend_pubkey(follow.ref_id)
+    }
 
+    return true
+}
+
+@discardableResult
+func handle_follow_notif(state: DamusState, notif: Notification) -> Bool {
+    let fnotify = notif.object as! FollowTarget
     switch fnotify {
     case .pubkey(let pk):
         state.contacts.add_friend_pubkey(pk)
     case .contact(let ev):
         state.contacts.add_friend_contact(ev)
     }
+
+    return handle_follow(state: state, follow: .p(fnotify.pubkey))
 }
 
 func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, notif: Notification) -> Bool {
diff --git a/damus/Models/Contacts.swift b/damus/Models/Contacts.swift
@@ -66,10 +66,19 @@ class Contacts {
         }
     }
     
-    func get_friend_list() -> [String] {
-        return Array(friends)
+    func get_friend_list() -> Set<String> {
+        return friends
     }
-    
+
+    func get_followed_hashtags() -> Set<String> {
+        guard let ev = self.event else { return Set() }
+        return ev.tags.reduce(into: Set<String>(), { htags, tag in
+            if tag.count >= 2 && tag[0] == "t" && tag[1] != "" {
+                htags.insert(tag[1])
+            }
+        })
+    }
+
     func add_friend_pubkey(_ pubkey: String) {
         friends.insert(pubkey)
     }
diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift
@@ -23,6 +23,40 @@ struct NewEventsBits: OptionSet {
     static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions]
 }
 
+enum Resubscribe {
+    case following
+    case unfollowing(ReferencedId)
+}
+
+enum HomeResubFilter {
+    case pubkey(String)
+    case hashtag(String)
+
+    init?(from: ReferencedId) {
+        if from.key == "p" {
+            self = .pubkey(from.ref_id)
+            return
+        } else if from.key == "t" {
+            self = .hashtag(from.ref_id)
+            return
+        }
+
+        return nil
+    }
+
+    func filter(contacts: Contacts, ev: NostrEvent) -> Bool {
+        switch self {
+        case .pubkey(let pk):
+            return ev.pubkey == pk
+        case .hashtag(let ht):
+            if contacts.is_friend(ev.pubkey) {
+                return false
+            }
+            return ev.references(id: ht, key: "t")
+        }
+    }
+}
+
 class HomeModel {
     // Don't trigger a user notification for events older than a certain age
     static let event_max_age_for_notification: TimeInterval = 12 * 60 * 60
@@ -36,6 +70,7 @@ class HomeModel {
     var done_init: Bool = false
     var incoming_dms: [NostrEvent] = []
     let dm_debouncer = Debouncer(interval: 0.5)
+    let resub_debouncer = Debouncer(interval: 3.0)
     var should_debounce_dms = true
 
     let home_subid = UUID().description
@@ -90,6 +125,31 @@ class HomeModel {
         }
     }
 
+    func resubscribe(_ resubbing: Resubscribe) {
+        if self.should_debounce_dms {
+            // don't resub on initial load
+            return
+        }
+
+        print("hit resub debouncer")
+
+        resub_debouncer.debounce {
+            print("resub")
+            self.unsubscribe_to_home_filters()
+
+            switch resubbing {
+            case .following:
+                break
+            case .unfollowing(let r):
+                if let filter = HomeResubFilter(from: r) {
+                    self.events.filter { ev in !filter.filter(contacts: self.damus_state.contacts, ev: ev) }
+                }
+            }
+
+            self.subscribe_to_home_filters()
+        }
+    }
+
     func process_event(sub_id: String, relay_id: String, ev: NostrEvent) {
         if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) {
             return
@@ -382,8 +442,7 @@ class HomeModel {
         // TODO: since times should be based on events from a specific relay
         // perhaps we could mark this in the relay pool somehow
 
-        var friends = damus_state.contacts.get_friend_list()
-        friends.append(damus_state.pubkey)
+        let friends = get_friends()
 
         var contacts_filter = NostrFilter(kinds: [.metadata])
         contacts_filter.authors = friends
@@ -405,18 +464,6 @@ class HomeModel {
         dms_filter.pubkeys = [ damus_state.pubkey ]
         our_dms_filter.authors = [ damus_state.pubkey ]
 
-        // TODO: separate likes?
-        var home_filter_kinds: [NostrKind] = [
-            .text, .longform, .boost
-        ]
-        if !damus_state.settings.onlyzaps_mode {
-            home_filter_kinds.append(.like)
-        }
-        var home_filter = NostrFilter(kinds: home_filter_kinds)
-        // include our pubkey as well even if we're not technically a friend
-        home_filter.authors = friends
-        home_filter.limit = 500
-
         var notifications_filter_kinds: [NostrKind] = [
             .text,
             .boost,
@@ -429,33 +476,71 @@ class HomeModel {
         notifications_filter.pubkeys = [damus_state.pubkey]
         notifications_filter.limit = 500
 
-        var home_filters = [home_filter]
         var notifications_filters = [notifications_filter]
         var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter]
         var dms_filters = [dms_filter, our_dms_filter]
+        let last_of_kind = get_last_of_kind(relay_id: relay_id)
 
-        let last_of_kind = relay_id.flatMap { last_event_of_kind[$0] } ?? [:]
-
-        home_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: home_filters)
         contacts_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: contacts_filters)
         notifications_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: notifications_filters)
         dms_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: dms_filters)
 
         //print_filters(relay_id: relay_id, filters: [home_filters, contacts_filters, notifications_filters, dms_filters])
 
-        if let relay_id {
-            pool.send(.subscribe(.init(filters: home_filters, sub_id: home_subid)), to: [relay_id])
-            pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid)), to: [relay_id])
-            pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid)), to: [relay_id])
-            pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)), to: [relay_id])
-        } else {
-            pool.send(.subscribe(.init(filters: home_filters, sub_id: home_subid)))
-            pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid)))
-            pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid)))
-            pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)))
+        subscribe_to_home_filters(relay_id: relay_id)
+
+        let relay_ids = relay_id.map { [$0] }
+
+        pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid)), to: relay_ids)
+        pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid)), to: relay_ids)
+        pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)), to: relay_ids)
+    }
+
+    func get_last_of_kind(relay_id: String?) -> [Int: NostrEvent] {
+        return relay_id.flatMap { last_event_of_kind[$0] } ?? [:]
+    }
+
+    func unsubscribe_to_home_filters() {
+        pool.send(.unsubscribe(home_subid))
+    }
+
+    func get_friends() -> [String] {
+        var friends = damus_state.contacts.get_friend_list()
+        friends.insert(damus_state.pubkey)
+        return Array(friends)
+    }
+
+    func subscribe_to_home_filters(friends fs: [String]? = nil, relay_id: String? = nil) {
+        // TODO: separate likes?
+        let home_filter_kinds: [NostrKind] = [
+            .text, .longform, .boost
+        ]
+        //if !damus_state.settings.onlyzaps_mode {
+            //home_filter_kinds.append(.like)
+        //}
+
+        let friends = fs ?? get_friends()
+        var home_filter = NostrFilter(kinds: home_filter_kinds)
+        // include our pubkey as well even if we're not technically a friend
+        home_filter.authors = friends
+        home_filter.limit = 500
+
+        var home_filters = [home_filter]
+
+        let followed_hashtags = Array(damus_state.contacts.get_followed_hashtags())
+        if followed_hashtags.count != 0 {
+            var hashtag_filter = NostrFilter.filter_hashtag(followed_hashtags)
+            hashtag_filter.limit = 100
+            home_filters.append(hashtag_filter)
         }
+
+        let relay_ids = relay_id.map { [$0] }
+        home_filters = update_filters_with_since(last_of_kind: get_last_of_kind(relay_id: relay_id), filters: home_filters)
+        let sub = NostrSubscribe(filters: home_filters, sub_id: home_subid)
+
+        pool.send(.subscribe(sub), to: relay_ids)
     }
-    
+
     func handle_list_event(_ ev: NostrEvent) {
         // we only care about our lists
         guard ev.pubkey == damus_state.pubkey else {
@@ -614,32 +699,34 @@ func add_contact_if_friend(contacts: Contacts, ev: NostrEvent) {
 
 func load_our_contacts(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
     let contacts = state.contacts
-    var new_pks = Set<String>()
+    var new_refs = Set<ReferencedId>()
     // our contacts
     for tag in ev.tags {
-        if tag.count >= 2 && tag[0] == "p" {
-            new_pks.insert(tag[1])
-        }
+        guard let ref = tag_to_refid(tag) else { continue }
+        new_refs.insert(ref)
     }
     
-    var old_pks = Set<String>()
+    var old_refs = Set<ReferencedId>()
     // find removed contacts
     if let old_ev = m_old_ev {
         for tag in old_ev.tags {
-            if tag.count >= 2 && tag[0] == "p" {
-                old_pks.insert(tag[1])
-            }
+            guard let ref = tag_to_refid(tag) else { continue }
+            old_refs.insert(ref)
         }
     }
     
-    let diff = new_pks.symmetricDifference(old_pks)
-    for pk in diff {
-        if new_pks.contains(pk) {
-            notify(.followed, pk)
-            contacts.add_friend_pubkey(pk)
+    let diff = new_refs.symmetricDifference(old_refs)
+    for ref in diff {
+        if new_refs.contains(ref) {
+            notify(.followed, ref)
+            if ref.key == "p" {
+                contacts.add_friend_pubkey(ref.ref_id)
+            }
         } else {
-            notify(.unfollowed, pk)
-            contacts.remove_friend(pk)
+            notify(.unfollowed, ref)
+            if ref.key == "p" {
+                contacts.remove_friend(ref.ref_id)
+            }
         }
     }
 
diff --git a/damus/Views/FollowButtonView.swift b/damus/Views/FollowButtonView.swift
@@ -32,16 +32,16 @@ struct FollowButtonView: View {
                 }
         }
         .onReceive(handle_notify(.followed)) { notif in
-            let pk = notif.object as! String
-            if pk != target.pubkey {
+            let pk = notif.object as! ReferencedId
+            if pk.key == "p", pk.ref_id != target.pubkey {
                 return
             }
             
             self.follow_state = .follows
         }
         .onReceive(handle_notify(.unfollowed)) { notif in
-            let pk = notif.object as! String
-            if pk != target.pubkey {
+            let pk = notif.object as! ReferencedId
+            if pk.key == "p", pk.ref_id != target.pubkey {
                 return
             }
             
diff --git a/damus/Views/SearchView.swift b/damus/Views/SearchView.swift
@@ -11,32 +11,70 @@ struct SearchView: View {
     let appstate: DamusState
     @ObservedObject var search: SearchModel
     @Environment(\.dismiss) var dismiss
-    
+
+    let height: CGFloat = 250.0
+
     var body: some View {
-        TimelineView<AnyView>(events: search.events, loading: $search.loading, damus: appstate, show_friend_icon: true, filter: { _ in true })
-            .navigationBarTitle(describe_search(search.search))
-            .onReceive(handle_notify(.switched_timeline)) { obj in
-                dismiss()
-            }
-            .onAppear() {
-                search.subscribe()
-            }
-            .onDisappear() {
-                search.unsubscribe()
-            }
-            .onReceive(handle_notify(.new_mutes)) { notif in
-                search.filter_muted()
+        TimelineView(events: search.events, loading: $search.loading, damus: appstate, show_friend_icon: true, filter: { _ in true }) {
+            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()
+        }
+    }
+
+    var described_search: DescribedSearch {
+        return describe_search(search.search)
+    }
+}
+
+enum DescribedSearch {
+    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 "Search"
+        }
     }
 }
 
-func describe_search(_ filter: NostrFilter) -> String {
+func describe_search(_ filter: NostrFilter) -> DescribedSearch {
     if let hashtags = filter.hashtag {
         if hashtags.count >= 1 {
-            return "#" + hashtags[0]
+            return .hashtag(hashtags[0])
         }
     }
-    return "Search"
+
+    return .unknown
 }
 
 struct SearchView_Previews: PreviewProvider {