diff --git a/NostrEvent.swift b/NostrEvent.swift index 2ddc48a..eb5efa7 100644 --- a/NostrEvent.swift +++ b/NostrEvent.swift @@ -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, diff --git a/NotificationRepository.swift b/NotificationRepository.swift index 229afaa..de61283 100644 --- a/NotificationRepository.swift +++ b/NotificationRepository.swift @@ -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 } diff --git a/SafetyFilter.swift b/SafetyFilter.swift index 030d77f..29b885b 100644 --- a/SafetyFilter.swift +++ b/SafetyFilter.swift @@ -20,15 +20,21 @@ final class SafetyFilterSnapshot: @unchecked Sendable { let qualifiedNetwork: Set // 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, blockedPubkeys: Set, mutedThreads: Set, - wotEnabled: Bool, qualifiedNetwork: Set, userPubkey: String) { + wotEnabled: Bool, qualifiedNetwork: Set, 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( @@ -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 @@ -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() @@ -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 )) } diff --git a/SafetyPreferences.swift b/SafetyPreferences.swift index 95af4b8..0529fb6 100644 --- a/SafetyPreferences.swift +++ b/SafetyPreferences.swift @@ -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 = [] { didSet { persist() } } @@ -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)) ?? []) } @@ -45,6 +55,8 @@ final class SafetyPreferences { activePubkey = nil spamFilterEnabled = true wotFilterEnabled = false + hellthreadFilterEnabled = false + hellthreadThreshold = NostrEvent.hellthreadThreshold spamSafelist = [] } @@ -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() { @@ -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() } } diff --git a/SafetySettingsView.swift b/SafetySettingsView.swift index b386f6b..4933f00 100644 --- a/SafetySettingsView.swift +++ b/SafetySettingsView.swift @@ -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) }