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