Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions NostrEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ nonisolated struct NostrEvent {
kind == 1 && !tags.contains { $0.first == "e" }
}

/// Default threshold for the hellthread filter. Events with at least this
/// many distinct p-tags are considered hellthreads.
static let hellthreadThreshold = 25

func isHellthread(threshold: Int = NostrEvent.hellthreadThreshold) -> Bool {
let uniquePubkeys = Set(tags.compactMap { $0.count >= 2 && $0[0] == "p" ? $0[1] : nil })
return uniquePubkeys.count >= threshold
}

init?(json: [String: Any]) {
guard let id = json["id"] as? String,
let pubkey = json["pubkey"] as? String,
Expand Down
10 changes: 10 additions & 0 deletions NotificationRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ final class NotificationRepository {
if isPrivate { item?.isPrivate = true }

guard let item else { return false }
// Hellthread reference suppression: reactions/zaps/reposts whose target
// event is a hellthread pass the p-tag count check (they have few p-tags
// themselves) but are still noise. If the referenced event is already in
// cache, drop immediately; if not, the event was never ingested so the
// notification is orphaned and will show nothing useful anyway.
let hellSnap = SafetyFilter.shared.snapshot
if hellSnap.hellthreadFilterEnabled,
!item.referencedEventId.isEmpty,
let referenced = eventCache[item.referencedEventId],
referenced.isHellthread(threshold: hellSnap.hellthreadThreshold) { return false }
// Self-zap (zapping your own note from your own wallet) — drop after
// classification, since `actorPubkey` is the resolved zap-request signer.
if item.kind == .zap && item.actorPubkey == activePubkey { return false }
Expand Down
25 changes: 21 additions & 4 deletions SafetyFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ final class SafetyFilterSnapshot: @unchecked Sendable {
let qualifiedNetwork: Set<String> // empty when WoT off or never computed — with
// wotEnabled that means FAIL CLOSED (drop all)
let userPubkey: String // empty before bind
let hellthreadFilterEnabled: Bool
let hellthreadThreshold: Int

init(mutedWords: Set<String>, blockedPubkeys: Set<String>, mutedThreads: Set<String>,
wotEnabled: Bool, qualifiedNetwork: Set<String>, userPubkey: String) {
wotEnabled: Bool, qualifiedNetwork: Set<String>, userPubkey: String,
hellthreadFilterEnabled: Bool = false,
hellthreadThreshold: Int = NostrEvent.hellthreadThreshold) {
self.mutedWords = mutedWords
self.blockedPubkeys = blockedPubkeys
self.mutedThreads = mutedThreads
self.wotEnabled = wotEnabled
self.qualifiedNetwork = qualifiedNetwork
self.userPubkey = userPubkey
self.hellthreadFilterEnabled = hellthreadFilterEnabled
self.hellthreadThreshold = hellthreadThreshold
}

static let empty = SafetyFilterSnapshot(
Expand Down Expand Up @@ -104,6 +110,15 @@ final class SafetyFilter: @unchecked Sendable {
}
}

if s.hellthreadFilterEnabled {
switch context {
case .feed, .notifications:
if event.isHellthread(threshold: s.hellthreadThreshold) { return true }
case .thread, .messages:
break
}
}

// Web of Trust — FAIL CLOSED: while the filter is on, any non-exempt
// event whose author can't be positively qualified is dropped, even
// when `qualifiedNetwork` is empty (never computed / cache lost). The
Expand Down Expand Up @@ -191,9 +206,9 @@ final class SafetyFilter: @unchecked Sendable {
let m = MuteRepository.shared
return (m.mutedWords, m.blockedPubkeys, m.mutedThreads)
}
let prefs: (wot: Bool, pubkey: String) = await MainActor.run {
let prefs: (wot: Bool, pubkey: String, hellthread: Bool, hellthreadThreshold: Int) = await MainActor.run {
let p = SafetyPreferences.shared
return (p.wotFilterEnabled, p.activePubkey ?? "")
return (p.wotFilterEnabled, p.activePubkey ?? "", p.hellthreadFilterEnabled, p.hellthreadThreshold)
}
let qualified = await ExtendedNetworkRepository.shared.qualifiedSet()

Expand All @@ -203,7 +218,9 @@ final class SafetyFilter: @unchecked Sendable {
mutedThreads: mutes.threads,
wotEnabled: prefs.wot,
qualifiedNetwork: qualified,
userPubkey: prefs.pubkey
userPubkey: prefs.pubkey,
hellthreadFilterEnabled: prefs.hellthread,
hellthreadThreshold: prefs.hellthreadThreshold
))
}

Expand Down
18 changes: 18 additions & 0 deletions SafetyPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ final class SafetyPreferences {
didSet { persist() }
}

var hellthreadFilterEnabled: Bool = false {
didSet { persist() }
}

var hellthreadThreshold: Int = NostrEvent.hellthreadThreshold {
didSet { persist() }
}

var spamSafelist: Set<String> = [] {
didSet { persist() }
}
Expand All @@ -36,6 +44,8 @@ final class SafetyPreferences {
let defaults = UserDefaults.standard
spamFilterEnabled = defaults.object(forKey: spamKey(pk)) as? Bool ?? true
wotFilterEnabled = defaults.bool(forKey: wotKey(pk))
hellthreadFilterEnabled = defaults.object(forKey: hellthreadKey(pk)) as? Bool ?? false
hellthreadThreshold = defaults.object(forKey: hellthreadThresholdKey(pk)) as? Int ?? NostrEvent.hellthreadThreshold
spamSafelist = Set(defaults.stringArray(forKey: safelistKey(pk)) ?? [])
}

Expand All @@ -45,6 +55,8 @@ final class SafetyPreferences {
activePubkey = nil
spamFilterEnabled = true
wotFilterEnabled = false
hellthreadFilterEnabled = false
hellthreadThreshold = NostrEvent.hellthreadThreshold
spamSafelist = []
}

Expand All @@ -62,10 +74,14 @@ final class SafetyPreferences {

static func spamKey(_ pubkey: String) -> String { "spam_filter_enabled_\(pubkey)" }
static func wotKey(_ pubkey: String) -> String { "wot_filter_enabled_\(pubkey)" }
static func hellthreadKey(_ pubkey: String) -> String { "hellthread_filter_enabled_\(pubkey)" }
static func hellthreadThresholdKey(_ pubkey: String) -> String { "hellthread_threshold_\(pubkey)" }
static func safelistKey(_ pubkey: String) -> String { "spam_safelist_\(pubkey)" }

private func spamKey(_ pubkey: String) -> String { Self.spamKey(pubkey) }
private func wotKey(_ pubkey: String) -> String { Self.wotKey(pubkey) }
private func hellthreadKey(_ pubkey: String) -> String { Self.hellthreadKey(pubkey) }
private func hellthreadThresholdKey(_ pubkey: String) -> String { Self.hellthreadThresholdKey(pubkey) }
private func safelistKey(_ pubkey: String) -> String { Self.safelistKey(pubkey) }

private func persist() {
Expand All @@ -74,6 +90,8 @@ final class SafetyPreferences {
let d = UserDefaults.standard
d.set(spamFilterEnabled, forKey: spamKey(pk))
d.set(wotFilterEnabled, forKey: wotKey(pk))
d.set(hellthreadFilterEnabled, forKey: hellthreadKey(pk))
d.set(hellthreadThreshold, forKey: hellthreadThresholdKey(pk))
d.set(Array(spamSafelist), forKey: safelistKey(pk))
Task { await SafetyFilter.shared.rebuildSnapshot() }
}
Expand Down
18 changes: 18 additions & 0 deletions SafetySettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,24 @@ struct SafetySettingsView: View {
// exists — the filter is fail-closed, so an enabled-but-empty
// network hides everything until the graph lands, and a failed
// compute has to revert the flip (see the stateStream observer).
Toggle("Hellthread filter", isOn: $prefs.hellthreadFilterEnabled)
.toggleStyle(SwitchToggleStyle(tint: theme.primary))
Text("Hides notes and notifications that tag more than the threshold number of distinct accounts.")
.font(.system(size: 12))
.foregroundStyle(theme.palette.onSurfaceVariant)
if prefs.hellthreadFilterEnabled {
Stepper(value: $prefs.hellthreadThreshold, in: 25...100, step: 5) {
HStack(spacing: 4) {
Text("Threshold:")
.font(.system(size: 14))
Text("\(prefs.hellthreadThreshold) mentions")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(theme.primary)
}
}
.padding(.bottom, 4)
}

Toggle("Web of Trust", isOn: Binding(
get: { prefs.wotFilterEnabled },
set: { handleWotToggle(on: $0) }
Expand Down