damus

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

commit 1ac96202420a4f10f73323e8a87c8e9d7fc71746
parent d5ecc9bce40cfac30984e2070615f48591799aa5
Author: Terry Yiu <963907+tyiu@users.noreply.github.com>
Date:   Sat,  8 Apr 2023 14:44:50 -0400

Add thread muting

Changelog-Added: Add thread muting
Closes: #893

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 7+++++++
Mdamus/ContentView.swift | 9++++++++-
Mdamus/Models/DamusState.swift | 3++-
Mdamus/Models/HomeModel.swift | 15+++++++++++----
Adamus/Models/MutedThreadsManager.swift | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/NotificationsModel.swift | 32++++++++++++++++++--------------
Mdamus/Util/Notifications.swift | 6++++++
Mdamus/Views/DMChatView.swift | 2+-
Mdamus/Views/EventView.swift | 4++--
Mdamus/Views/Events/EmbeddedEventView.swift | 2+-
Mdamus/Views/Events/EventMenu.swift | 26+++++++++++++++++++++++---
Mdamus/Views/Events/SelectedEventView.swift | 2+-
Mdamus/Views/Events/TextEvent.swift | 2+-
Mdamus/Views/Notifications/NotificationsView.swift | 4++--
Mdamus/Views/SearchHomeView.swift | 4++++
15 files changed, 163 insertions(+), 31 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 3A3040F329A91366008A0F29 /* ProfileViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3040F229A91366008A0F29 /* ProfileViewTests.swift */; }; 3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A30410029AB12AA008A0F29 /* EventGroupViewTests.swift */; }; 3A4325A82961E11400BFCD9D /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 3A4325AA2961E11400BFCD9D /* Localizable.stringsdict */; }; + 3A48E7B029DFBE9D006E787E /* MutedThreadsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */; }; 3AA247FD297E3CFF0090C62D /* RepostsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FC297E3CFF0090C62D /* RepostsModel.swift */; }; 3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; }; 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.swift */; }; @@ -328,6 +329,10 @@ 3A41E559299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = "<group>"; }; 3A41E55A299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; }; 3A41E55B299D52BE001FA465 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = id; path = id.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; + 3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutedThreadsManager.swift; sourceTree = "<group>"; }; + 3A4F3320297CCFEE004B5F72 /* fr-FR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-FR"; path = "fr-FR.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; + 3A4F3321297CCFEE004B5F72 /* fr-FR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-FR"; path = "fr-FR.lproj/Localizable.strings"; sourceTree = "<group>"; }; + 3A4F3322297CCFEE004B5F72 /* fr-FR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "fr-FR"; path = "fr-FR.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; 3A5C4575296A879E0032D398 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "es-419"; path = "es-419.lproj/Localizable.stringsdict"; sourceTree = "<group>"; }; 3A5CAE1D298DC0DB00B5334F /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-CN"; path = "zh-CN.lproj/InfoPlist.strings"; sourceTree = "<group>"; }; 3A5CAE1E298DC0DB00B5334F /* zh-CN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-CN"; path = "zh-CN.lproj/Localizable.strings"; sourceTree = "<group>"; }; @@ -837,6 +842,7 @@ 3AA59D1C2999B0400061C48E /* DraftsModel.swift */, 4C54AA0629A540BA003E4487 /* NotificationsModel.swift */, 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */, + 3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */, ); path = Models; sourceTree = "<group>"; @@ -1531,6 +1537,7 @@ 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */, 4CB883A62975F83C00DC99E7 /* LNUrlPayRequest.swift in Sources */, 4CE4F9DE2852768D00C00DD9 /* ConfigView.swift in Sources */, + 3A48E7B029DFBE9D006E787E /* MutedThreadsManager.swift in Sources */, 4C285C8E28399BFE008A31F1 /* SaveKeysView.swift in Sources */, F7F0BA25297892BD009531F3 /* SwipeToDismiss.swift in Sources */, 4C8D00CA29DF80350036AF10 /* TruncatedText.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -476,6 +476,12 @@ struct ContentView: View { .onReceive(handle_notify(.new_mutes)) { notif in home.filter_muted() } + .onReceive(handle_notify(.mute_thread)) { notif in + home.filter_muted() + } + .onReceive(handle_notify(.unmute_thread)) { notif in + home.filter_muted() + } .alert(NSLocalizedString("Deleted Account", comment: "Alert message to indicate this is a deleted account"), isPresented: $is_deleted_account) { Button(NSLocalizedString("Logout", comment: "Button to close the alert that informs that the current account has been deleted.")) { is_deleted_account = false @@ -638,7 +644,8 @@ struct ContentView: View { bookmarks: BookmarksManager(pubkey: pubkey), postbox: PostBox(pool: pool), bootstrap_relays: bootstrap_relays, - replies: ReplyCounter(our_pubkey: pubkey) + replies: ReplyCounter(our_pubkey: pubkey), + muted_threads: MutedThreadsManager(pubkey: pubkey) ) home.damus_state = self.damus_state! diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift @@ -29,6 +29,7 @@ struct DamusState { let postbox: PostBox let bootstrap_relays: [String] let replies: ReplyCounter + let muted_threads: MutedThreadsManager var pubkey: String { return keypair.pubkey @@ -39,6 +40,6 @@ struct DamusState { } static var empty: DamusState { - return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: "")) + return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), muted_threads: MutedThreadsManager(pubkey: "")) } } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -52,12 +52,14 @@ class HomeModel: ObservableObject { init() { self.damus_state = DamusState.empty self.dms = DirectMessagesModel(our_pubkey: "") + filter_muted() } init(damus_state: DamusState) { self.damus_state = damus_state self.dms = DirectMessagesModel(our_pubkey: damus_state.pubkey) self.setup_debouncer() + filter_muted() } var pool: RelayPool { @@ -134,7 +136,7 @@ class HomeModel: ObservableObject { return } - if !notifications.insert_zap(zap) { + if !notifications.insert_zap(zap, damus_state: damus_state) { return } @@ -197,9 +199,9 @@ class HomeModel: ObservableObject { } func filter_muted() { - events.filter { !damus_state.contacts.is_muted($0.pubkey) } + events.filter { !damus_state.contacts.is_muted($0.pubkey) && !damus_state.muted_threads.isMutedThread($0) } self.dms.dms = dms.dms.filter { !damus_state.contacts.is_muted($0.0) } - notifications.filter { !damus_state.contacts.is_muted($0.pubkey) } + notifications.filter_and_build_notifications(damus_state) } func handle_delete_event(_ ev: NostrEvent) { @@ -478,7 +480,7 @@ class HomeModel: ObservableObject { damus_state.events.insert(inner_ev) } - if !notifications.insert_event(ev) { + if !notifications.insert_event(ev, damus_state: damus_state) { return } @@ -1044,6 +1046,11 @@ func process_local_notification(damus_state: DamusState, event ev: NostrEvent) { return } + // Don't show notifications from muted threads. + if damus_state.muted_threads.isMutedThread(ev) { + return + } + if type == .text && damus_state.settings.mention_notification { for block in ev.blocks(damus_state.keypair.privkey) { if case .mention(let mention) = block, mention.ref.ref_id == damus_state.keypair.pubkey, diff --git a/damus/Models/MutedThreadsManager.swift b/damus/Models/MutedThreadsManager.swift @@ -0,0 +1,76 @@ +// +// MutedThreadsManager.swift +// damus +// +// Created by Terry Yiu on 4/6/23. +// + +import Foundation + +fileprivate func getMutedThreadsKey(pubkey: String) -> String { + pk_setting_key(pubkey, key: "muted_threads") +} + +func loadMutedThreads(pubkey: String) -> [String] { + let key = getMutedThreadsKey(pubkey: pubkey) + return UserDefaults.standard.stringArray(forKey: key) ?? [] +} + +func saveMutedThreads(pubkey: String, currentValue: [String], value: [String]) -> Bool { + let uniqueMutedThreads = Array(Set(value)) + + if uniqueMutedThreads != currentValue { + UserDefaults.standard.set(uniqueMutedThreads, forKey: getMutedThreadsKey(pubkey: pubkey)) + return true + } + + return false +} + +class MutedThreadsManager: ObservableObject { + + private let userDefaults = UserDefaults.standard + private let pubkey: String + + private var _mutedThreadsSet: Set<String> + private var _mutedThreads: [String] + var mutedThreads: [String] { + get { + return _mutedThreads + } + set { + if saveMutedThreads(pubkey: pubkey, currentValue: _mutedThreads, value: newValue) { + self._mutedThreads = newValue + self.objectWillChange.send() + } + } + } + + init(pubkey: String) { + self._mutedThreads = loadMutedThreads(pubkey: pubkey) + self._mutedThreadsSet = Set(_mutedThreads) + self.pubkey = pubkey + } + + func isMutedThread(_ ev: NostrEvent) -> Bool { + return _mutedThreadsSet.contains(ev.thread_id(privkey: nil)) + } + + func updateMutedThread(_ ev: NostrEvent) { + let threadId = ev.thread_id(privkey: nil) + if isMutedThread(ev) { + mutedThreads = mutedThreads.filter { $0 != threadId } + _mutedThreadsSet.remove(threadId) + notify(.unmute_thread, ev) + } else { + mutedThreads.append(threadId) + _mutedThreadsSet.insert(threadId) + notify(.mute_thread, ev) + } + } + + func clearAll() { + mutedThreads = [] + _mutedThreadsSet.removeAll() + } +} diff --git a/damus/Models/NotificationsModel.swift b/damus/Models/NotificationsModel.swift @@ -129,7 +129,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { for el in zaps { let evid = el.key let zapgrp = el.value - + let notif: NotificationItem = .event_zap(evid, zapgrp) notifs.append(notif) } @@ -233,66 +233,66 @@ class NotificationsModel: ObservableObject, ScrollQueue { } } - func insert_event(_ ev: NostrEvent) -> Bool { + func insert_event(_ ev: NostrEvent, damus_state: DamusState) -> Bool { if should_queue { return insert_uniq_sorted_event_created(events: &incoming_events, new_ev: ev) } if insert_event_immediate(ev) { - self.notifications = build_notifications() + filter_and_build_notifications(damus_state) return true } return false } - func insert_zap(_ zap: Zap) -> Bool { + func insert_zap(_ zap: Zap, damus_state: DamusState) -> Bool { if should_queue { return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap) } if insert_zap_immediate(zap) { - self.notifications = build_notifications() + filter_and_build_notifications(damus_state) return true } return false } - func filter(_ isIncluded: (NostrEvent) -> Bool) { + func filter_and_build_notifications(_ damus_state: DamusState) { var changed = false var count = 0 count = incoming_events.count - incoming_events = incoming_events.filter(isIncluded) + incoming_events = incoming_events.filter { include_event($0, damus_state: damus_state) } changed = changed || incoming_events.count != count count = profile_zaps.zaps.count - profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request.ev) } + profile_zaps.zaps = profile_zaps.zaps.filter { zap in include_event(zap.request.ev, damus_state: damus_state) } changed = changed || profile_zaps.zaps.count != count for el in reactions { count = el.value.events.count - el.value.events = el.value.events.filter(isIncluded) + el.value.events = el.value.events.filter { include_event($0, damus_state: damus_state) } changed = changed || el.value.events.count != count } for el in reposts { count = el.value.events.count - el.value.events = el.value.events.filter(isIncluded) + el.value.events = el.value.events.filter { include_event($0, damus_state: damus_state) } changed = changed || el.value.events.count != count } for el in zaps { count = el.value.zaps.count el.value.zaps = el.value.zaps.filter { - isIncluded($0.request.ev) + include_event($0.request.ev, damus_state: damus_state) } changed = changed || el.value.zaps.count != count } count = replies.count - replies = replies.filter(isIncluded) + replies = replies.filter { include_event($0, damus_state: damus_state) } changed = changed || replies.count != count if changed { @@ -300,7 +300,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { } } - func flush() -> Bool { + func flush(_ damus_state: DamusState) -> Bool { var inserted = false for zap in incoming_zaps { @@ -312,9 +312,13 @@ class NotificationsModel: ObservableObject, ScrollQueue { } if inserted { - self.notifications = build_notifications() + filter_and_build_notifications(damus_state) } return inserted } + + func include_event(_ event: NostrEvent, damus_state: DamusState) -> Bool { + return !damus_state.contacts.is_muted(event.pubkey) && !damus_state.muted_threads.isMutedThread(event) + } } diff --git a/damus/Util/Notifications.swift b/damus/Util/Notifications.swift @@ -104,6 +104,12 @@ extension Notification.Name { static var zapping: Notification.Name { return Notification.Name("zapping") } + static var mute_thread: Notification.Name { + return Notification.Name("mute_thread") + } + static var unmute_thread: Notification.Name { + return Notification.Name("unmute_thread") + } } func handle_notify(_ name: Notification.Name) -> NotificationCenter.Publisher { diff --git a/damus/Views/DMChatView.swift b/damus/Views/DMChatView.swift @@ -19,7 +19,7 @@ struct DMChatView: View { VStack(alignment: .leading) { ForEach(Array(zip(dms.events, dms.events.indices)), id: \.0.id) { (ev, ind) in DMView(event: dms.events[ind], damus_state: damus_state) - .contextMenu{MenuItems(event: ev, keypair: damus_state.keypair, target_pubkey: ev.pubkey, bookmarks: damus_state.bookmarks)} + .contextMenu{MenuItems(event: ev, keypair: damus_state.keypair, target_pubkey: ev.pubkey, bookmarks: damus_state.bookmarks, muted_threads: damus_state.muted_threads)} } EndBlock(height: 80) } diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift @@ -93,9 +93,9 @@ extension View { } } - func event_context_menu(_ event: NostrEvent, keypair: Keypair, target_pubkey: String, bookmarks: BookmarksManager) -> some View { + func event_context_menu(_ event: NostrEvent, keypair: Keypair, target_pubkey: String, bookmarks: BookmarksManager, muted_threads: MutedThreadsManager) -> some View { return self.contextMenu { - EventMenuContext(event: event, keypair: keypair, target_pubkey: target_pubkey, bookmarks: bookmarks) + EventMenuContext(event: event, keypair: keypair, target_pubkey: target_pubkey, bookmarks: bookmarks, muted_threads: muted_threads) } } diff --git a/damus/Views/Events/EmbeddedEventView.swift b/damus/Views/Events/EmbeddedEventView.swift @@ -23,7 +23,7 @@ struct EmbeddedEventView: View { Spacer() - EventMenuContext(event: event, keypair: damus_state.keypair, target_pubkey: event.pubkey, bookmarks: damus_state.bookmarks) + EventMenuContext(event: event, keypair: damus_state.keypair, target_pubkey: event.pubkey, bookmarks: damus_state.bookmarks, muted_threads: damus_state.muted_threads) .padding([.bottom], 4) } diff --git a/damus/Views/Events/EventMenu.swift b/damus/Views/Events/EventMenu.swift @@ -12,6 +12,7 @@ struct EventMenuContext: View { let keypair: Keypair let target_pubkey: String let bookmarks: BookmarksManager + let muted_threads: MutedThreadsManager @Environment(\.colorScheme) var colorScheme @@ -19,7 +20,7 @@ struct EventMenuContext: View { HStack { Menu { - MenuItems(event: event, keypair: keypair, target_pubkey: target_pubkey, bookmarks: bookmarks) + MenuItems(event: event, keypair: keypair, target_pubkey: target_pubkey, bookmarks: bookmarks, muted_threads: muted_threads) } label: { Label("", systemImage: "ellipsis") @@ -36,14 +37,20 @@ struct MenuItems: View { let keypair: Keypair let target_pubkey: String let bookmarks: BookmarksManager + let muted_threads: MutedThreadsManager @State private var isBookmarked: Bool = false + @State private var isMutedThread: Bool = false - init(event: NostrEvent, keypair: Keypair, target_pubkey: String, bookmarks: BookmarksManager) { + init(event: NostrEvent, keypair: Keypair, target_pubkey: String, bookmarks: BookmarksManager, muted_threads: MutedThreadsManager) { let bookmarked = bookmarks.isBookmarked(event) self._isBookmarked = State(initialValue: bookmarked) + + let muted_thread = muted_threads.isMutedThread(event) + self._isMutedThread = State(initialValue: muted_thread) self.bookmarks = bookmarks + self.muted_threads = muted_threads self.event = event self.keypair = keypair self.target_pubkey = target_pubkey @@ -86,6 +93,19 @@ struct MenuItems: View { Label(isBookmarked ? removeBookmarkString : addBookmarkString, systemImage: imageName) } + if event.known_kind != .dm { + Button { + self.muted_threads.updateMutedThread(event) + let muted = self.muted_threads.isMutedThread(event) + isMutedThread = muted + } label: { + let imageName = isMutedThread ? "speaker" : "speaker.slash" + let unmuteThreadString = NSLocalizedString("Unmute conversation", comment: "Context menu option for unmuting a conversation.") + let muteThreadString = NSLocalizedString("Mute conversation", comment: "Context menu option for muting a conversation.") + Label(isMutedThread ? unmuteThreadString : muteThreadString, systemImage: imageName) + } + } + Button { NotificationCenter.default.post(name: .broadcast_event, object: event) } label: { @@ -104,7 +124,7 @@ struct MenuItems: View { Button(role: .destructive) { notify(.mute, target_pubkey) } label: { - Label(NSLocalizedString("Mute", comment: "Context menu option for muting users."), systemImage: "exclamationmark.octagon") + Label(NSLocalizedString("Mute User", comment: "Context menu option for muting users."), systemImage: "exclamationmark.octagon") } } } diff --git a/damus/Views/Events/SelectedEventView.swift b/damus/Views/Events/SelectedEventView.swift @@ -35,7 +35,7 @@ struct SelectedEventView: View { Spacer() - EventMenuContext(event: event, keypair: damus.keypair, target_pubkey: event.pubkey, bookmarks: damus.bookmarks) + EventMenuContext(event: event, keypair: damus.keypair, target_pubkey: event.pubkey, bookmarks: damus.bookmarks, muted_threads: damus.muted_threads) .padding([.bottom], 4) } diff --git a/damus/Views/Events/TextEvent.swift b/damus/Views/Events/TextEvent.swift @@ -109,7 +109,7 @@ struct TextEvent: View { } var ContextButton: some View { - EventMenuContext(event: event, keypair: damus.keypair, target_pubkey: event.pubkey, bookmarks: damus.bookmarks) + EventMenuContext(event: event, keypair: damus.keypair, target_pubkey: event.pubkey, bookmarks: damus.bookmarks, muted_threads: damus.muted_threads) .padding([.bottom], 4) } diff --git a/damus/Views/Notifications/NotificationsView.swift b/damus/Views/Notifications/NotificationsView.swift @@ -103,13 +103,13 @@ struct NotificationsView: View { } .coordinateSpace(name: "scroll") .onReceive(handle_notify(.scroll_to_top)) { notif in - let _ = notifications.flush() + let _ = notifications.flush(state) self.notifications.should_queue = false scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top) } } .onAppear { - let _ = notifications.flush() + let _ = notifications.flush(state) } } } diff --git a/damus/Views/SearchHomeView.swift b/damus/Views/SearchHomeView.swift @@ -50,6 +50,10 @@ struct SearchHomeView: View { damus: damus_state, show_friend_icon: true, filter: { + if damus_state.muted_threads.isMutedThread($0) { + return false + } + if damus_state.settings.show_only_preferred_languages == false { return true }