damus

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

commit e65219ee3e942ab3ab359ed8a2d59c36919ba665
parent 414c67a91903d8c8820e6e37ef59f742800f4ecf
Author: Terry Yiu <git@tyiu.xyz>
Date:   Tue,  3 Jun 2025 23:13:24 -0400

Add web of trust reply sorting in threads to mitigate spam

Changelog-Added: Added web of trust reply sorting in threads to mitigate spam
Signed-off-by: Terry Yiu <git@tyiu.xyz>

Diffstat:
Mdamus/Models/UserSettingsStore.swift | 5++++-
Mdamus/Views/Chat/ChatEventView.swift | 6------
Mdamus/Views/Chat/ChatroomThreadView.swift | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mdamus/Views/Settings/AppearanceSettingsView.swift | 2++
4 files changed, 193 insertions(+), 98 deletions(-)

diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -127,7 +127,10 @@ class UserSettingsStore: ObservableObject { @Setting(key: "media_previews", default_value: true) var media_previews: Bool - + + @Setting(key: "show_trusted_replies_first", default_value: true) + var show_trusted_replies_first: Bool + @Setting(key: "hide_nsfw_tagged_content", default_value: false) var hide_nsfw_tagged_content: Bool diff --git a/damus/Views/Chat/ChatEventView.swift b/damus/Views/Chat/ChatEventView.swift @@ -337,12 +337,6 @@ struct ChatEventView: View { } } -extension Notification.Name { - static var toggle_thread_view: Notification.Name { - return Notification.Name("convert_to_thread") - } -} - #Preview { let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state) return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar) diff --git a/damus/Views/Chat/ChatroomThreadView.swift b/damus/Views/Chat/ChatroomThreadView.swift @@ -15,11 +15,20 @@ struct ChatroomThreadView: View { @ObservedObject var thread: ThreadModel @State var highlighted_note_id: NoteId? = nil @State var user_just_posted_flag: Bool = false + @State var untrusted_network_expanded: Bool = true @Namespace private var animation - - + + // Add state for sticky header + @State var showStickyHeader: Bool = false + @State var untrustedSectionOffset: CGFloat = 0 + + private static let untrusted_network_section_id = "untrusted-network-section" + private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2) + func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) { - scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top) + let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top + + scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: adjustedAnchor) highlighted_note_id = note_id DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { withAnimation { @@ -27,7 +36,7 @@ struct ChatroomThreadView: View { } }) } - + func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) { withAnimation { self.thread.select(event: ev) @@ -35,93 +44,192 @@ struct ChatroomThreadView: View { } } + func trusted_event_filter(_ event: NostrEvent) -> Bool { + !damus.settings.show_trusted_replies_first || damus.contacts.is_in_friendosphere(event.pubkey) + } + + func ThreadedSwipeViewGroup(scroller: ScrollViewProxy, events: [NostrEvent]) -> some View { + SwipeViewGroup { + ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in + ChatEventView(event: events[ind], + selected_event: self.thread.selected_event, + prev_ev: ind > 0 ? events[ind-1] : nil, + next_ev: ind == events.count-1 ? nil : events[ind+1], + damus_state: damus, + thread: thread, + scroll_to_event: { note_id in + self.go_to_event(scroller: scroller, note_id: note_id) + }, + focus_event: { + self.set_active_event(scroller: scroller, ev: ev) + }, + highlight_bubble: highlighted_note_id == ev.id, + bar: make_actionbar_model(ev: ev.id, damus: damus) + ) + .id(ev.id) + .matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center) + .padding(.horizontal) + } + } + } + + var OutsideTrustedNetworkLabel: some View { + HStack { + Label( + NSLocalizedString( + "Replies outside your trusted network", + comment: "Section title in thread for replies from outside of the current user's trusted network, which is their follows and follows of follows."), + systemImage: "network.slash" + ) + Spacer() + Image(systemName: "chevron.right") + .rotationEffect(.degrees(untrusted_network_expanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.1), value: untrusted_network_expanded) + } + .foregroundColor(.secondary) + } + + var StickyHeaderView: some View { + OutsideTrustedNetworkLabel + .padding(.horizontal) + .padding(.vertical, 12) + .background( + Color(UIColor.systemBackground) + .shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2) + ) + } + var body: some View { ScrollViewReader { scroller in - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 8) { - // MARK: - Parents events view - ForEach(thread.parent_events, id: \.id) { parent_event in - EventMutingContainerView(damus_state: damus, event: parent_event) { - EventView(damus: damus, event: parent_event) - .matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center) - } - .padding(.horizontal) - .onTapGesture { - self.set_active_event(scroller: scroller, ev: parent_event) + let sorted_child_events = thread.sorted_child_events + + let untrusted_events = sorted_child_events.filter { !trusted_event_filter($0) } + let trusted_events = sorted_child_events.filter { trusted_event_filter($0) } + + ZStack(alignment: .top) { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 8) { + // MARK: - Parents events view + ForEach(thread.parent_events, id: \.id) { parent_event in + EventMutingContainerView(damus_state: damus, event: parent_event) { + EventView(damus: damus, event: parent_event) + .matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center) + } + .padding(.horizontal) + .onTapGesture { + self.set_active_event(scroller: scroller, ev: parent_event) + } + .id(parent_event.id) + + Divider() + .padding(.top, 4) + .padding(.leading, 25 * 2) + + }.background(GeometryReader { geometry in + let eventHeight = geometry.frame(in: .global).height + + Rectangle() + .fill(Color.gray.opacity(0.25)) + .frame(width: 2, height: eventHeight) + .offset(x: 40, y: 40) + }) + + // MARK: - Actual event view + EventMutingContainerView( + damus_state: damus, + event: self.thread.selected_event, + muteBox: { event_shown, muted_reason in + AnyView( + EventMutedBoxView(shown: event_shown, reason: muted_reason) + .padding(5) + ) + } + ) { + SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected) + .matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center) } - .id(parent_event.id) - - Divider() - .padding(.top, 4) - .padding(.leading, 25 * 2) - - }.background(GeometryReader { geometry in - // get the height and width of the EventView view - let eventHeight = geometry.frame(in: .global).height - // let eventWidth = geometry.frame(in: .global).width - - // vertical gray line in the background - Rectangle() - .fill(Color.gray.opacity(0.25)) - .frame(width: 2, height: eventHeight) - .offset(x: 40, y: 40) - }) - - // MARK: - Actual event view - EventMutingContainerView( - damus_state: damus, - event: self.thread.selected_event, - muteBox: { event_shown, muted_reason in - AnyView( - EventMutedBoxView(shown: event_shown, reason: muted_reason) - .padding(5) - ) + .id(self.thread.selected_event.id) + + // MARK: - Children view - inside trusted network + if !trusted_events.isEmpty { + ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events) } - ) { - SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected) - .matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center) } - .id(self.thread.selected_event.id) - - - // MARK: - Children view - let events = thread.sorted_child_events - let count = events.count - SwipeViewGroup { - ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in - ChatEventView(event: events[ind], - selected_event: self.thread.selected_event, - prev_ev: ind > 0 ? events[ind-1] : nil, - next_ev: ind == count-1 ? nil : events[ind+1], - damus_state: damus, - thread: thread, - scroll_to_event: { note_id in - self.go_to_event(scroller: scroller, note_id: note_id) - }, - focus_event: { - self.set_active_event(scroller: scroller, ev: ev) - }, - highlight_bubble: highlighted_note_id == ev.id, - bar: make_actionbar_model(ev: ev.id, damus: damus) - ) + .padding(.top) + + // MARK: - Children view - outside trusted network + if !untrusted_events.isEmpty { + VStack(alignment: .leading, spacing: 0) { + // Track this section's position + Color.clear + .frame(height: 1) + .background( + GeometryReader { proxy in + Color.clear + .onAppear { + untrustedSectionOffset = proxy.frame(in: .global).minY + } + .onChange(of: proxy.frame(in: .global).minY) { newY in + let shouldShow = newY <= 100 // Adjust this threshold as needed + if shouldShow != showStickyHeader { + withAnimation(.easeInOut(duration: 0.3)) { + showStickyHeader = shouldShow + } + } + } + } + ) + + Button(action: { + withAnimation { + untrusted_network_expanded.toggle() + + scroll_to_event(scroller: scroller, id: ChatroomThreadView.untrusted_network_section_id, delay: 0.1, animate: true, anchor: ChatroomThreadView.sticky_header_adjusted_anchor) + } + }) { + OutsideTrustedNetworkLabel + } + .id(ChatroomThreadView.untrusted_network_section_id) + .buttonStyle(PlainButtonStyle()) .padding(.horizontal) - .id(ev.id) - .matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center) + + if untrusted_network_expanded { + withAnimation { + LazyVStack(alignment: .leading, spacing: 8) { + ThreadedSwipeViewGroup(scroller: scroller, events: untrusted_events) + } + .padding(.top, 10) + } + } } } + + EndBlock() + + HStack {} + .frame(height: tabHeight + getSafeAreaBottom()) + } + + if showStickyHeader && !untrusted_events.isEmpty { + VStack { + StickyHeaderView + .onTapGesture { + withAnimation { + untrusted_network_expanded.toggle() + } + } + Spacer() + } + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) } - .padding(.top) - EndBlock() - - HStack {} - .frame(height: tabHeight + getSafeAreaBottom()) } .onReceive(handle_notify(.post), perform: { notify in switch notify { - case .post(_): - user_just_posted_flag = true - case .cancel: - return + case .post(_): + user_just_posted_flag = true + case .cancel: + return } }) .onReceive(thread.objectWillChange) { @@ -139,15 +247,8 @@ struct ChatroomThreadView: View { } } } - - func toggle_thread_view() { - NotificationCenter.default.post(name: .toggle_thread_view, object: nil) - } } - - - struct ChatroomView_Previews: PreviewProvider { static var previews: some View { Group { @@ -167,8 +268,3 @@ struct ChatroomView_Previews: PreviewProvider { } } } - -@MainActor -func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) { - scroll_to_event(scroller: proxy, id: thread.selected_event.id, delay: 0.1, animate: false) -} diff --git a/damus/Views/Settings/AppearanceSettingsView.swift b/damus/Views/Settings/AppearanceSettingsView.swift @@ -100,6 +100,8 @@ struct AppearanceSettingsView: View { header: Text("Content filters", comment: "Section title for content filtering/moderation configuration."), footer: Text("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Section footer clarifying what #nsfw (not safe for work) tags mean") ) { + Toggle(NSLocalizedString("Show replies from your trusted network first", comment: "Setting to show replies in threads from the current user's trusted network first."), isOn: $settings.show_trusted_replies_first) + .toggleStyle(.switch) Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with the #nsfw (not safe for work) tags"), isOn: $settings.hide_nsfw_tagged_content) .toggleStyle(.switch) }