damus

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

commit 75d66434f3045bd5fa6d541233e82f778df9096f
parent 61a9e44898bda3d7888c116e555f6d02f201a1d9
Author: Charlie Fish <contact@charlie.fish>
Date:   Wed, 17 Jan 2024 18:17:41 -0700

mute: updating UI to support new mute list

This patch depends on: Adding filtering support for MuteItem events

- Gives more specific mute reason in EventMutedBoxView
- Showing all types of mutes in MutelistView
- Allowing for adding mutes directly from MutelistView
- Allowing for choosing duration of mute in EventMenu

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus/Util/Router.swift | 6+++---
Mdamus/Views/DMChatView.swift | 2+-
Mdamus/Views/Events/EventMenu.swift | 53++++++++++++++++++++++-------------------------------
Mdamus/Views/Events/EventMutingContainerView.swift | 28++++++++++++++++++++--------
Mdamus/Views/Muting/MutelistView.swift | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mdamus/Views/Profile/ProfileView.swift | 7+++++--
Mdamus/Views/Reposts/RepostedEvent.swift | 4++--
Mdamus/Views/SideMenuView.swift | 2+-
Mdamus/Views/ThreadView.swift | 4++--
9 files changed, 141 insertions(+), 73 deletions(-)

diff --git a/damus/Util/Router.swift b/damus/Util/Router.swift @@ -14,7 +14,7 @@ enum Route: Hashable { case Relay(relay: String, showActionButtons: Binding<Bool>) case RelayDetail(relay: String, metadata: RelayMetadata?) case Following(following: FollowingModel) - case MuteList(users: [Pubkey]) + case MuteList(mutelist_items: Set<MuteItem>) case RelayConfig case Script(script: ScriptModel) case Bookmarks @@ -58,8 +58,8 @@ enum Route: Hashable { RelayDetailView(state: damusState, relay: relay, nip11: metadata) case .Following(let following): FollowingView(damus_state: damusState, following: following) - case .MuteList(let users): - MutelistView(damus_state: damusState, users: users) + case .MuteList(let mutelist_items): + MutelistView(damus_state: damusState, mutelist_items: mutelist_items) case .RelayConfig: RelayConfigView(state: damusState) case .Bookmarks: diff --git a/damus/Views/DMChatView.swift b/damus/Views/DMChatView.swift @@ -22,7 +22,7 @@ struct DMChatView: View, KeyboardReadable { LazyVStack(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, muted_threads: damus_state.muted_threads, settings: damus_state.settings, profileModel: ProfileModel(pubkey: ev.pubkey, damus: damus_state))} + .contextMenu{MenuItems(damus_state: damus_state, event: ev, target_pubkey: ev.pubkey, profileModel: ProfileModel(pubkey: ev.pubkey, damus: damus_state))} } EndBlock(height: 1) } diff --git a/damus/Views/Events/EventMenu.swift b/damus/Views/Events/EventMenu.swift @@ -8,21 +8,15 @@ import SwiftUI struct EventMenuContext: View { + let damus_state: DamusState let event: NostrEvent - let keypair: Keypair let target_pubkey: Pubkey - let bookmarks: BookmarksManager - let muted_threads: MutedThreadsManager let profileModel : ProfileModel - @ObservedObject var settings: UserSettingsStore init(damus: DamusState, event: NostrEvent) { + self.damus_state = damus self.event = event - self.keypair = damus.keypair self.target_pubkey = event.pubkey - self.bookmarks = damus.bookmarks - self.muted_threads = damus.muted_threads - self._settings = ObservedObject(wrappedValue: damus.settings) self.profileModel = ProfileModel(pubkey: target_pubkey, damus: damus) } @@ -34,7 +28,7 @@ struct EventMenuContext: View { // Add our Menu button inside an overlay modifier to avoid affecting the rest of the layout around us. .overlay( Menu { - MenuItems(event: event, keypair: keypair, target_pubkey: target_pubkey, bookmarks: bookmarks, muted_threads: muted_threads, settings: settings, profileModel: profileModel) + MenuItems(damus_state: damus_state, event: event, target_pubkey: target_pubkey, profileModel: profileModel) } label: { Color.clear } @@ -49,38 +43,31 @@ struct EventMenuContext: View { } struct MenuItems: View { + let damus_state: DamusState let event: NostrEvent - let keypair: Keypair let target_pubkey: Pubkey - let bookmarks: BookmarksManager - let muted_threads: MutedThreadsManager let profileModel: ProfileModel - @ObservedObject var settings: UserSettingsStore - @State private var isBookmarked: Bool = false @State private var isMutedThread: Bool = false - init(event: NostrEvent, keypair: Keypair, target_pubkey: Pubkey, bookmarks: BookmarksManager, muted_threads: MutedThreadsManager, settings: UserSettingsStore, profileModel: ProfileModel) { - let bookmarked = bookmarks.isBookmarked(event) + init(damus_state: DamusState, event: NostrEvent, target_pubkey: Pubkey, profileModel: ProfileModel) { + let bookmarked = damus_state.bookmarks.isBookmarked(event) self._isBookmarked = State(initialValue: bookmarked) - let muted_thread = muted_threads.isMutedThread(event, keypair: keypair) + let muted_thread = (damus_state.contacts.mutelist?.mute_list?.event_muted_reason(event) != nil) self._isMutedThread = State(initialValue: muted_thread) - self.bookmarks = bookmarks - self.muted_threads = muted_threads + self.damus_state = damus_state self.event = event - self.keypair = keypair self.target_pubkey = target_pubkey - self.settings = settings self.profileModel = profileModel } var body: some View { Group { Button { - UIPasteboard.general.string = event.get_content(keypair) + UIPasteboard.general.string = event.get_content(damus_state.keypair) } label: { Label(NSLocalizedString("Copy text", comment: "Context menu option for copying the text from an note."), image: "copy2") } @@ -97,7 +84,7 @@ struct MenuItems: View { Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book") } - if settings.developer_mode { + if damus_state.settings.developer_mode { Button { UIPasteboard.general.string = event_to_json(ev: event) } label: { @@ -106,8 +93,8 @@ struct MenuItems: View { } Button { - self.bookmarks.updateBookmark(event) - isBookmarked = self.bookmarks.isBookmarked(event) + self.damus_state.bookmarks.updateBookmark(event) + isBookmarked = self.damus_state.bookmarks.isBookmarked(event) } label: { let imageName = isBookmarked ? "bookmark.fill" : "bookmark" let removeBookmarkString = NSLocalizedString("Remove bookmark", comment: "Context menu option for removing a note bookmark.") @@ -122,9 +109,13 @@ struct MenuItems: View { } // Mute thread - relocated to below Broadcast, as to move further away from Add Bookmark to prevent accidental muted threads if event.known_kind != .dm { - Button { - self.muted_threads.updateMutedThread(event) - let muted = self.muted_threads.isMutedThread(event, keypair: self.keypair) + MuteDurationMenu { duration in + if let full_keypair = self.damus_state.keypair.to_full(), + let new_mutelist_ev = toggle_from_mutelist(keypair: full_keypair, prev: damus_state.contacts.mutelist, to_toggle: .thread(event.thread_id(keypair: damus_state.keypair), duration?.date_from_now)) { + damus_state.contacts.set_mutelist(new_mutelist_ev) + damus_state.postbox.send(new_mutelist_ev) + } + let muted = (damus_state.contacts.mutelist?.mute_list?.event_muted_reason(event) != nil) isMutedThread = muted } label: { let imageName = isMutedThread ? "mute" : "mute" @@ -134,15 +125,15 @@ struct MenuItems: View { } } // Only allow reporting if logged in with private key and the currently viewed profile is not the logged in profile. - if keypair.pubkey != target_pubkey && keypair.privkey != nil { + if damus_state.keypair.pubkey != target_pubkey && damus_state.keypair.privkey != nil { Button(role: .destructive) { notify(.report(.note(ReportNoteTarget(pubkey: target_pubkey, note_id: event.id)))) } label: { Label(NSLocalizedString("Report", comment: "Context menu option for reporting content."), image: "raising-hand") } - Button(role: .destructive) { - notify(.mute(.user(target_pubkey, nil))) + MuteDurationMenu { duration in + notify(.mute(.user(target_pubkey, duration?.date_from_now))) } label: { Label(NSLocalizedString("Mute user", comment: "Context menu option for muting users."), image: "mute") } diff --git a/damus/Views/Events/EventMutingContainerView.swift b/damus/Views/Events/EventMutingContainerView.swift @@ -9,15 +9,20 @@ import SwiftUI /// A container view that shows or hides provided content based on whether the given event should be muted or not, with built-in user controls to show or hide content, and an option to customize the muted box struct EventMutingContainerView<Content: View>: View { - typealias MuteBoxViewClosure = ((_ shown: Binding<Bool>) -> AnyView) - + typealias MuteBoxViewClosure = ((_ shown: Binding<Bool>, _ mutedReason: MuteItem?) -> AnyView) + let damus_state: DamusState let event: NostrEvent let content: Content var customMuteBox: MuteBoxViewClosure? + /// Represents if the note itself should be shown. + /// + /// By default this is the same as `should_show_event`. However, if the user taps the button to manually show a muted note, this can become out of sync with `should_show_event`. @State var shown: Bool - + + @State var muted_reason: MuteItem? + init(damus_state: DamusState, event: NostrEvent, @ViewBuilder content: () -> Content) { self.damus_state = damus_state self.event = event @@ -38,10 +43,10 @@ struct EventMutingContainerView<Content: View>: View { Group { if should_mute { if let customMuteBox { - customMuteBox($shown) + customMuteBox($shown, muted_reason) } else { - EventMutedBoxView(shown: $shown) + EventMutedBoxView(shown: $shown, reason: muted_reason) } } if shown { @@ -52,11 +57,13 @@ struct EventMutingContainerView<Content: View>: View { let new_muted_event_reason = mutes.event_muted_reason(event) if new_muted_event_reason != nil { shown = false + muted_reason = new_muted_event_reason } } .onReceive(handle_notify(.new_unmutes)) { unmutes in if unmutes.event_muted_reason(event) != nil { shown = true + muted_reason = nil } } } @@ -65,16 +72,21 @@ struct EventMutingContainerView<Content: View>: View { /// A box that instructs the user about a content that has been muted. struct EventMutedBoxView: View { @Binding var shown: Bool - + var reason: MuteItem? + var body: some View { ZStack { RoundedRectangle(cornerRadius: 20) .foregroundColor(DamusColors.adaptableGrey) HStack { - Text("Note from a user you've muted", comment: "Text to indicate that what is being shown is a note from a user who has been muted.") + if let reason { + Text("Note from a \(reason.title) you've muted", comment: "Text to indicate that what is being shown is a note which has been muted.") + } else { + Text("Note you've muted", comment: "Text to indicate that what is being shown is a note which has been muted.") + } Spacer() - Button(shown ? NSLocalizedString("Hide", comment: "Button to hide a note from a user who has been muted.") : NSLocalizedString("Show", comment: "Button to show a note from a user who has been muted.")) { + Button(shown ? NSLocalizedString("Hide", comment: "Button to hide a note which has been muted.") : NSLocalizedString("Show", comment: "Button to show a note which has been muted.")) { shown.toggle() } } diff --git a/damus/Views/Muting/MutelistView.swift b/damus/Views/Muting/MutelistView.swift @@ -9,55 +9,117 @@ import SwiftUI struct MutelistView: View { let damus_state: DamusState - @State var users: [Pubkey] - - func RemoveAction(pubkey: Pubkey) -> some View { + @State var mutelist_items: Set<MuteItem> = Set<MuteItem>() + @State var show_add_muteitem: Bool = false + + func RemoveAction(item: MuteItem) -> some View { Button { guard let mutelist = damus_state.contacts.mutelist, let keypair = damus_state.keypair.to_full(), let new_ev = remove_from_mutelist(keypair: keypair, prev: mutelist, - to_remove: .user(pubkey, nil)) + to_remove: item) else { return } - + damus_state.contacts.set_mutelist(new_ev) damus_state.postbox.send(new_ev) - users = get_mutelist_users(new_ev) + mutelist_items = new_ev.mute_list ?? Set<MuteItem>() } label: { Label(NSLocalizedString("Delete", comment: "Button to remove a user from their mutelist."), image: "delete") } .tint(.red) } - + var body: some View { - List(users, id: \.self) { pubkey in - UserViewRow(damus_state: damus_state, pubkey: pubkey) - .id(pubkey) - .swipeActions { - RemoveAction(pubkey: pubkey) + List { + Section(NSLocalizedString("Users", comment: "Section header title for a list of muted users.")) { + ForEach(mutelist_items.users, id: \.self) { pubkey in + UserViewRow(damus_state: damus_state, pubkey: pubkey) + .id(pubkey) + .swipeActions { + RemoveAction(item: .user(pubkey, nil)) + } + .onTapGesture { + damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey)) + } + } + } + Section(NSLocalizedString("Hashtags", comment: "Section header title for a list of hashtags that are muted.")) { + ForEach(mutelist_items.hashtags, id: \.hashtag) { hashtag in + Text("#\(hashtag.hashtag)") + .id(hashtag.hashtag) + .swipeActions { + RemoveAction(item: .hashtag(hashtag, nil)) + } + .onTapGesture { + damus_state.nav.push(route: Route.Search(search: SearchModel.init(state: damus_state, search: NostrFilter(hashtag: [hashtag.hashtag])))) + } } - .onTapGesture { - damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey)) + } + Section(NSLocalizedString("Words", comment: "Section header title for a list of words that are muted.")) { + ForEach(mutelist_items.words, id: \.self) { word in + Text("\(word)") + .id(word) + .swipeActions { + RemoveAction(item: .word(word, nil)) + } + } + } + Section(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")) { + ForEach(mutelist_items.threads, id: \.self) { note_id in + if let event = damus_state.events.lookup(note_id) { + EventView(damus: damus_state, event: event) + .id(note_id.hex()) + .swipeActions { + RemoveAction(item: .thread(note_id, nil)) + } + } else { + Text(NSLocalizedString("Error retrieving muted event", comment: "Text for an item that application failed to retrieve the muted event for.")) + } } + } } - .navigationTitle(NSLocalizedString("Muted Users", comment: "Navigation title of view to see list of muted users.")) + .navigationTitle(NSLocalizedString("Muted", comment: "Navigation title of view to see list of muted users & phrases.")) .onAppear { - users = get_mutelist_users(damus_state.contacts.mutelist) + mutelist_items = damus_state.contacts.mutelist?.mute_list ?? Set<MuteItem>() + } + .onReceive(handle_notify(.new_mutes)) { new_mutes in + mutelist_items = mutelist_items.union(new_mutes) + } + .onReceive(handle_notify(.new_unmutes)) { new_unmutes in + mutelist_items = mutelist_items.subtracting(new_unmutes) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + self.show_add_muteitem = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $show_add_muteitem, onDismiss: { self.show_add_muteitem = false }) { + if #available(iOS 16.0, *) { + AddMuteItemView(state: damus_state) + .presentationDetents([.height(300)]) + .presentationDragIndicator(.visible) + } else { + AddMuteItemView(state: damus_state) + } } } } - -func get_mutelist_users(_ mutelist: NostrEvent?) -> Array<Pubkey> { - guard let mutelist else { return [] } - return Array(mutelist.referenced_pubkeys) -} - struct MutelistView_Previews: PreviewProvider { static var previews: some View { - MutelistView(damus_state: test_damus_state, users: [test_note.pubkey, test_note.pubkey]) + MutelistView(damus_state: test_damus_state, mutelist_items: Set([ + .user(test_note.pubkey, nil), + .hashtag(Hashtag(hashtag: "test"), nil), + .word("test", nil), + .thread(test_note.id, nil) + ])) } } diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift @@ -196,8 +196,11 @@ struct ProfileView: View { damus_state.postbox.send(new_ev) } } else { - Button(NSLocalizedString("Mute", comment: "Button to mute a profile."), role: .destructive) { - notify(.mute(.user(profile.pubkey, nil))) + MuteDurationMenu { duration in + notify(.mute(.user(profile.pubkey, duration?.date_from_now))) + } label: { + Text(NSLocalizedString("Mute", comment: "Button to mute a profile.")) + .foregroundStyle(.red) } } } diff --git a/damus/Views/Reposts/RepostedEvent.swift b/damus/Views/Reposts/RepostedEvent.swift @@ -25,9 +25,9 @@ struct RepostedEvent: View { EventMutingContainerView( damus_state: damus, event: inner_ev, - muteBox: { event_shown in + muteBox: { event_shown, muted_reason in AnyView( - EventMutedBoxView(shown: event_shown) + EventMutedBoxView(shown: event_shown, reason: muted_reason) .padding(.horizontal, 5) // Add a bit of horizontal padding to avoid the mute box from touching the edges of the screen ) }) { diff --git a/damus/Views/SideMenuView.swift b/damus/Views/SideMenuView.swift @@ -66,7 +66,7 @@ struct SideMenuView: View { } } - NavigationLink(value: Route.MuteList(users: get_mutelist_users(damus_state.contacts.mutelist))) { + NavigationLink(value: Route.MuteList(mutelist_items: damus_state.contacts.mutelist?.mute_list ?? Set<MuteItem>())) { navLabel(title: NSLocalizedString("Muted", comment: "Sidebar menu label for muted users view."), img: "mute") } diff --git a/damus/Views/ThreadView.swift b/damus/Views/ThreadView.swift @@ -70,9 +70,9 @@ struct ThreadView: View { EventMutingContainerView( damus_state: state, event: self.thread.event, - muteBox: { event_shown in + muteBox: { event_shown, muted_reason in AnyView( - EventMutedBoxView(shown: event_shown) + EventMutedBoxView(shown: event_shown, reason: muted_reason) .padding(5) ) }