Compare commits

...

2 Commits

Author SHA1 Message Date
dddb95e0b3 Inverse hellthread_notifications_enabled to be hellthread_notifications_disabled and add hellthread_notifications_max_pubkeys setting
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-06 11:48:39 -04:00
bf333e55b8 Add notification setting to hide hellthreads
Changelog-Added: Add notification setting to hide hellthreads
Closes: https://github.com/damus-io/damus/issues/2943
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-03-29 17:56:26 -04:00
11 changed files with 219 additions and 97 deletions

View File

@@ -207,7 +207,7 @@
4C64987C286D03E000EAE2B3 /* DirectMessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */; };
4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */; };
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 4C649880286E0EE300EAE2B3 /* secp256k1 */; };
4C684A552A7E91FE005E6031 /* LongPostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C684A542A7E91FE005E6031 /* LongPostTests.swift */; };
4C684A552A7E91FE005E6031 /* LargeEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C684A542A7E91FE005E6031 /* LargeEventTests.swift */; };
4C684A572A7FFAE6005E6031 /* UrlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C684A562A7FFAE6005E6031 /* UrlTests.swift */; };
4C687C212A5F7ED00092C550 /* DamusBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C202A5F7ED00092C550 /* DamusBackground.swift */; };
4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */; };
@@ -2176,7 +2176,7 @@
4C64305B2A945AFF00B0C0E9 /* MusicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicController.swift; sourceTree = "<group>"; };
4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesView.swift; sourceTree = "<group>"; };
4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesModel.swift; sourceTree = "<group>"; };
4C684A542A7E91FE005E6031 /* LongPostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongPostTests.swift; sourceTree = "<group>"; };
4C684A542A7E91FE005E6031 /* LargeEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeEventTests.swift; sourceTree = "<group>"; };
4C684A562A7FFAE6005E6031 /* UrlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlTests.swift; sourceTree = "<group>"; };
4C687C202A5F7ED00092C550 /* DamusBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusBackground.swift; sourceTree = "<group>"; };
4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHeaderView.swift; sourceTree = "<group>"; };
@@ -3719,7 +3719,7 @@
4C19AE542A5D977400C90DB7 /* HashtagTests.swift */,
3AAC7A012A60FE72002B50DF /* LocalizationUtilTests.swift */,
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */,
4C684A542A7E91FE005E6031 /* LongPostTests.swift */,
4C684A542A7E91FE005E6031 /* LargeEventTests.swift */,
4C684A562A7FFAE6005E6031 /* UrlTests.swift */,
D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */,
D71DC1EB2A9129C3006E207C /* PostViewTests.swift */,
@@ -4978,7 +4978,7 @@
D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */,
3A3040F329A91366008A0F29 /* ProfileViewTests.swift in Sources */,
4CF0ABDC2981A19E00D66079 /* ListTests.swift in Sources */,
4C684A552A7E91FE005E6031 /* LongPostTests.swift in Sources */,
4C684A552A7E91FE005E6031 /* LargeEventTests.swift in Sources */,
E02B54182B4DFADA0077FF42 /* Bech32ObjectTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -41,6 +41,10 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
return false
}
if state.settings.hellthread_notifications_disabled && ev.is_hellthread(max_pubkeys: state.settings.hellthread_notification_max_pubkeys) {
return false
}
// Don't show notifications that match mute list.
if state.mutelist_manager.is_event_muted(ev) {
return false

View File

@@ -7,6 +7,12 @@
import Foundation
// Minimum threshold the hellthread pubkey tag count setting can go down to.
let HELLTHREAD_MIN_PUBKEYS: Int = 6
// Maximum threshold the hellthread pubkey tag count setting can go up to.
let HELLTHREAD_MAX_PUBKEYS: Int = 24
struct PushNotificationClient {
let keypair: Keypair
let settings: UserSettingsStore
@@ -175,15 +181,33 @@ extension PushNotificationClient {
}
struct NotificationSettings: Codable, Equatable {
let zap_notifications_enabled: Bool
let mention_notifications_enabled: Bool
let repost_notifications_enabled: Bool
let reaction_notifications_enabled: Bool
let dm_notifications_enabled: Bool
let only_notifications_from_following_enabled: Bool
let zap_notifications_enabled: Bool?
let mention_notifications_enabled: Bool?
let repost_notifications_enabled: Bool?
let reaction_notifications_enabled: Bool?
let dm_notifications_enabled: Bool?
let only_notifications_from_following_enabled: Bool?
let hellthread_notifications_disabled: Bool?
let hellthread_notifications_max_pubkeys: Int?
static func from(json_data: Data) -> Self? {
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
// Normalize hellthread_notifications_max_pubkeys in case
// it goes beyond the expected range supported on the client.
if let max_pubkeys = decoded.hellthread_notifications_max_pubkeys, max_pubkeys < HELLTHREAD_MIN_PUBKEYS || max_pubkeys > HELLTHREAD_MAX_PUBKEYS {
return NotificationSettings(
zap_notifications_enabled: decoded.zap_notifications_enabled,
mention_notifications_enabled: decoded.mention_notifications_enabled,
repost_notifications_enabled: decoded.repost_notifications_enabled,
reaction_notifications_enabled: decoded.reaction_notifications_enabled,
dm_notifications_enabled: decoded.dm_notifications_enabled,
only_notifications_from_following_enabled: decoded.only_notifications_from_following_enabled,
hellthread_notifications_disabled: decoded.hellthread_notifications_disabled,
hellthread_notifications_max_pubkeys: max(min(HELLTHREAD_MAX_PUBKEYS, max_pubkeys), HELLTHREAD_MIN_PUBKEYS)
)
}
return decoded
}
@@ -194,7 +218,9 @@ extension PushNotificationClient {
repost_notifications_enabled: settings.repost_notification,
reaction_notifications_enabled: settings.like_notification,
dm_notifications_enabled: settings.dm_notification,
only_notifications_from_following_enabled: settings.notification_only_from_following
only_notifications_from_following_enabled: settings.notification_only_from_following,
hellthread_notifications_disabled: settings.hellthread_notifications_disabled,
hellthread_notifications_max_pubkeys: settings.hellthread_notification_max_pubkeys
)
}

View File

@@ -160,7 +160,13 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "notification_only_from_following", default_value: false)
var notification_only_from_following: Bool
@Setting(key: "hellthread_notifications_disabled", default_value: false)
var hellthread_notifications_disabled: Bool
@Setting(key: "hellthread_notification_max_pubkeys", default_value: DEFAULT_HELLTHREAD_MAX_PUBKEYS)
var hellthread_notification_max_pubkeys: Int
@Setting(key: "translate_dms", default_value: false)
var translate_dms: Bool

View File

@@ -9,15 +9,27 @@ import SwiftUI
class NotificationFilter: ObservableObject, Equatable {
@Published var state: NotificationFilterState
@Published var fine_filter: FriendFilter
@Published var friend_filter: FriendFilter
@Published var hellthread_notifications_disabled: Bool
@Published var hellthread_notification_max_pubkeys: Int
static func == (lhs: NotificationFilter, rhs: NotificationFilter) -> Bool {
return lhs.state == rhs.state && lhs.fine_filter == rhs.fine_filter
return lhs.state == rhs.state
&& lhs.friend_filter == rhs.friend_filter
&& lhs.hellthread_notifications_disabled == rhs.hellthread_notifications_disabled
&& lhs.hellthread_notification_max_pubkeys == rhs.hellthread_notification_max_pubkeys
}
init(state: NotificationFilterState = .all, fine_filter: FriendFilter = .all) {
init(
state: NotificationFilterState = .all,
friend_filter: FriendFilter = .all,
hellthread_notifications_disabled: Bool = false,
hellthread_notification_max_pubkeys: Int = DEFAULT_HELLTHREAD_MAX_PUBKEYS
) {
self.state = state
self.fine_filter = fine_filter
self.friend_filter = friend_filter
self.hellthread_notifications_disabled = hellthread_notifications_disabled
self.hellthread_notification_max_pubkeys = hellthread_notification_max_pubkeys
}
func filter(contacts: Contacts, items: [NotificationItem]) -> [NotificationItem] {
@@ -26,8 +38,11 @@ class NotificationFilter: ObservableObject, Equatable {
if !self.state.filter(item) {
return
}
if let item = item.filter({ self.fine_filter.filter(contacts: contacts, pubkey: $0.pubkey) }) {
if let item = item.filter({ ev in
self.friend_filter.filter(contacts: contacts, pubkey: ev.pubkey) &&
(!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys))
}) {
acc.append(item)
}
}
@@ -65,7 +80,9 @@ struct NotificationsView: View {
NotificationTab(
NotificationFilter(
state: .all,
fine_filter: filter.fine_filter
friend_filter: filter.friend_filter,
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
)
)
.tag(NotificationFilterState.all)
@@ -73,7 +90,9 @@ struct NotificationsView: View {
NotificationTab(
NotificationFilter(
state: .zaps,
fine_filter: filter.fine_filter
friend_filter: filter.friend_filter,
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
)
)
.tag(NotificationFilterState.zaps)
@@ -81,7 +100,9 @@ struct NotificationsView: View {
NotificationTab(
NotificationFilter(
state: .replies,
fine_filter: filter.fine_filter
friend_filter: filter.friend_filter,
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
)
)
.tag(NotificationFilterState.replies)
@@ -98,20 +119,20 @@ struct NotificationsView: View {
}
ToolbarItem(placement: .navigationBarTrailing) {
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
FriendsButton(filter: $filter.fine_filter)
FriendsButton(filter: $filter.friend_filter)
}
}
}
.onChange(of: filter.fine_filter) { val in
.onChange(of: filter.friend_filter) { val in
state.settings.friend_filter = val
self.subtitle = filter.fine_filter.description()
self.subtitle = filter.friend_filter.description()
}
.onChange(of: filter_state) { val in
filter.state = val
}
.onAppear {
self.filter.fine_filter = state.settings.friend_filter
self.subtitle = filter.fine_filter.description()
self.filter.friend_filter = state.settings.friend_filter
self.subtitle = filter.friend_filter.description()
filter.state = filter_state
}
.safeAreaInset(edge: .top, spacing: 0) {

View File

@@ -16,7 +16,7 @@ struct NotificationSettingsView: View {
@State var notification_preferences_sync_state: PreferencesSyncState = .undefined
@Environment(\.dismiss) var dismiss
func indicator_binding(_ val: NewEventsBits) -> Binding<Bool> {
return Binding.init(get: {
(settings.notification_indicators & val.rawValue) > 0
@@ -28,7 +28,15 @@ struct NotificationSettingsView: View {
}
})
}
var hellthread_notification_max_pubkeys_binding: Binding<Double> {
Binding<Double>(get: {
return Double(settings.hellthread_notification_max_pubkeys)
}, set: {
settings.hellthread_notification_max_pubkeys = Int($0)
})
}
func try_to_set_notifications_mode(new_value: UserSettingsStore.NotificationsMode) {
notification_mode_setting_error = nil
if new_value == .push {
@@ -111,7 +119,24 @@ struct NotificationSettingsView: View {
}
// MARK: - View layout
func hellthread_notification_settings_text() -> String {
if !settings.hellthread_notifications_disabled {
return NSLocalizedString("Hide notifications that tag many profiles", comment: "Label for notification settings toggle that hides notifications that tag many people.")
}
return pluralizedString(key: "hellthread_notifications_disabled", count: $settings.hellthread_notification_max_pubkeys.wrappedValue)
}
var hellthread_notifications_max_pubkeys_view: some View {
VStack(alignment: .leading) {
Slider(
value: self.notification_preference_binding(hellthread_notification_max_pubkeys_binding),
in: Double(HELLTHREAD_MIN_PUBKEYS)...Double(HELLTHREAD_MAX_PUBKEYS),
step: 1
)
}
}
var body: some View {
Form {
if settings.enable_push_notifications {
@@ -175,6 +200,13 @@ struct NotificationSettingsView: View {
.toggleStyle(.switch)
Toggle(NSLocalizedString("Show only from users you follow", comment: "Setting to Show notifications only associated to users your follow"), isOn: self.notification_preference_binding($settings.notification_only_from_following))
.toggleStyle(.switch)
VStack {
Toggle(hellthread_notification_settings_text(), isOn: self.notification_preference_binding($settings.hellthread_notifications_disabled))
.toggleStyle(.switch)
if settings.hellthread_notifications_disabled {
hellthread_notifications_max_pubkeys_view
}
}
}
Section(

View File

@@ -50,6 +50,22 @@
<string>Following</string>
</dict>
</dict>
<key>hellthread_notifications_disabled</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@HELLTHREAD_PROFILES@</string>
<key>HELLTHREAD_PROFILES</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>Hide notifications that tag more than %d profile</string>
<key>other</key>
<string>Hide notifications that tag more than %d profiles</string>
</dict>
</dict>
<key>imports_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
@@ -82,6 +98,22 @@
<string>%2$@ and %1$d others reposted</string>
</dict>
</dict>
<key>quoted_reposts_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@QUOTE_REPOSTS@</string>
<key>QUOTE_REPOSTS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>Quote</string>
<key>other</key>
<string>Quotes</string>
</dict>
</dict>
<key>reacted_tagged_in_3</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
@@ -242,22 +274,6 @@
<string>Reposts</string>
</dict>
</dict>
<key>quoted_reposts_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@QUOTE_REPOSTS@</string>
<key>QUOTE_REPOSTS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>Quote</string>
<key>other</key>
<string>Quotes</string>
</dict>
</dict>
<key>sats</key>
<dict>
<key>NSStringLocalizedFormatKey</key>

View File

@@ -0,0 +1,50 @@
//
// LargeEventTests.swift
// damusTests
//
// Created by William Casarin on 2023-08-05.
//
import XCTest
@testable import damus
final class LargeEventTests: XCTestCase {
func testLongPost() throws {
let json = "[\"EVENT\",\"subid\",\(test_failing_nostr_report)]"
let resp = NostrResponse.owned_from_json(json: json)
XCTAssertNotNil(resp)
guard let resp,
case .event(let subid, let ev) = resp
else {
XCTAssertFalse(true)
return
}
XCTAssertEqual(subid, "subid")
XCTAssertTrue(ev.should_show_event)
XCTAssertTrue(!ev.too_big)
XCTAssertTrue(should_show_event(state: test_damus_state, ev: ev))
XCTAssertTrue(validate_event(ev: ev) == .ok)
}
func testIsHellthread() throws {
let json = "[\"EVENT\",\"subid\",\(test_failing_nostr_report)]"
let resp = NostrResponse.owned_from_json(json: json)
XCTAssertNotNil(resp)
guard let resp,
case .event(let subid, let ev) = resp
else {
XCTAssertFalse(true)
return
}
XCTAssertEqual(subid, "subid")
XCTAssertTrue(ev.should_show_event)
XCTAssertTrue(ev.is_hellthread(max_pubkeys: 10))
XCTAssertTrue(validate_event(ev: ev) == .ok)
}
}

View File

@@ -17,13 +17,16 @@ final class LocalizationUtilTests: XCTestCase {
let keys = [
["followers_count", "Followers", "Follower", "Followers"],
["following_count", "Following", "Following", "Following"],
["hellthread_notifications_disabled", "Hide notifications that tag more than 0 profiles", "Hide notifications that tag more than 1 profile", "Hide notifications that tag more than 2 profiles"],
["imports_count", "Imports", "Import", "Imports"],
["quoted_reposts_count", "Quotes", "Quote", "Quotes"],
["reactions_count", "Reactions", "Reaction", "Reactions"],
["relays_count", "Relays", "Relay", "Relays"],
["reposts_count", "Reposts", "Repost", "Reposts"],
["sats", "sats", "sat", "sats"],
["zaps_count", "Zaps", "Zap", "Zaps"],
["word_count", "0 Words", "1 Word", "2 Words"]
["users_talking_about_it", "0 users talking about it", "1 user talking about it", "2 users talking about it"],
["word_count", "0 Words", "1 Word", "2 Words"],
["zaps_count", "Zaps", "Zap", "Zaps"]
]
for key in keys {

View File

@@ -1,48 +0,0 @@
//
// LongPostTests.swift
// damusTests
//
// Created by William Casarin on 2023-08-05.
//
import XCTest
@testable import damus
final class LongPostTests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func testLongPost() throws {
let contacts = Contacts(our_pubkey: test_keypair.pubkey)
let json = "[\"EVENT\",\"subid\",\(test_failing_nostr_report)]"
let resp = NostrResponse.owned_from_json(json: json)
XCTAssertNotNil(resp)
guard let resp,
case .event(let subid, let ev) = resp
else {
XCTAssertFalse(true)
return
}
XCTAssertEqual(subid, "subid")
XCTAssertTrue(ev.should_show_event)
XCTAssertTrue(!ev.too_big)
XCTAssertTrue(should_show_event(state: test_damus_state, ev: ev))
XCTAssertTrue(validate_event(ev: ev) == .ok )
}
func testPerformanceExample() throws {
// This is an example of a performance test case.
self.measure {
// Put the code you want to measure the time of here.
}
}
}

View File

@@ -14,6 +14,9 @@ import CryptoKit
let MAX_NOTE_SIZE: Int = 2 << 18
// Default threshold of the hellthread pubkey tag count setting if it is not set.
let DEFAULT_HELLTHREAD_MAX_PUBKEYS: Int = 10
struct NdbStr {
let note: NdbNote
let str: UnsafePointer<CChar>
@@ -299,6 +302,15 @@ extension NdbNote {
return !too_big
}
func is_hellthread(max_pubkeys: Int) -> Bool {
switch known_kind {
case .text, .boost, .like, .zap:
Set(referenced_pubkeys).count > max_pubkeys
default:
false
}
}
func get_blocks(keypair: Keypair) -> Blocks {
return parse_note_content(content: .init(note: self, keypair: keypair))
}