Add the ability to follow hashtags

Changelog-Added: Add the ability to follow hashtags
This commit is contained in:
William Casarin
2023-07-13 11:10:53 -07:00
7 changed files with 410 additions and 96 deletions

View File

@@ -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 */,

View File

@@ -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)
}
}
}

View File

@@ -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) {
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
}
return nil
}
@discardableResult
func handle_follow(state: DamusState, follow: ReferencedId) -> Bool {
guard let keypair = state.keypair.to_full() else {
return
return false
}
let fnotify = notif.object as! FollowTarget
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 {

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
func describe_search(_ filter: NostrFilter) -> String {
if let hashtags = filter.hashtag {
if hashtags.count >= 1 {
return "#" + hashtags[0]
enum DescribedSearch {
case hashtag(String)
case unknown
var is_hashtag: String? {
switch self {
case .hashtag(let ht):
return ht
case .unknown:
return nil
}
}
return "Search"
var description: String {
switch self {
case .hashtag(let s):
return "#" + s
case .unknown:
return "Search"
}
}
}
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 {