damus

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

commit 50f45288ce0cf86777d50fd102e30a43fd4a1636
parent 5840b852138282f0857f75816f8c354947275b95
Author: Charlie Fish <contact@charlie.fish>
Date:   Wed, 17 Jan 2024 18:17:36 -0700

mute: adding new structs/enums for new mute list

- Adding MuteItem & DamusDuration
- Changing RefId hashtag associated type from TagElem to Hashtag
    - This is done because in MuteItem, we can not create a RefId.hashtag TagElem instance since we don’t have a note associated with a given hashtag mute item.

Related: https://github.com/damus-io/damus/issues/1718
Related: https://github.com/damus-io/damus/issues/856
Lighting Address: fishcharlie@strike.me

Signed-off-by: Charlie Fish <contact@charlie.fish>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 14++++++++++++++
Mdamus/ContentView.swift | 2+-
Mdamus/Models/Contacts+.swift | 2+-
Adamus/Models/MuteItem.swift | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/ReferencedId.swift | 6+++---
Adamus/Types/DamusDuration.swift | 38++++++++++++++++++++++++++++++++++++++
AdamusTests/Models/MuteItemTests.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 323 insertions(+), 5 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -424,7 +424,11 @@ B57B4C622B312BD700A232C0 /* ReconnectRelaysNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B4C612B312BD700A232C0 /* ReconnectRelaysNotify.swift */; }; B57B4C642B312BFA00A232C0 /* RelayAuthenticationDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B4C632B312BFA00A232C0 /* RelayAuthenticationDetail.swift */; }; B57B4C662B312C3700A232C0 /* NostrAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B4C652B312C3700A232C0 /* NostrAuth.swift */; }; + B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */; }; B5B4D1432B37D47600844320 /* NdbExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B4D1422B37D47600844320 /* NdbExtensions.swift */; }; + B5C60C202B530D5100C5ECA7 /* MuteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */; }; + B5C60C212B530D5600C5ECA7 /* MuteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */; }; + B5C60C232B532A8700C5ECA7 /* DamusDuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */; }; BA37598A2ABCCDE40018D73B /* ImageResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759892ABCCDE30018D73B /* ImageResizer.swift */; }; BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */; }; BA37598E2ABCCE500018D73B /* VideoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */; }; @@ -1316,7 +1320,10 @@ B57B4C612B312BD700A232C0 /* ReconnectRelaysNotify.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReconnectRelaysNotify.swift; sourceTree = "<group>"; }; B57B4C632B312BFA00A232C0 /* RelayAuthenticationDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelayAuthenticationDetail.swift; sourceTree = "<group>"; }; B57B4C652B312C3700A232C0 /* NostrAuth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NostrAuth.swift; sourceTree = "<group>"; }; + B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteItemTests.swift; sourceTree = "<group>"; usesTabs = 0; }; B5B4D1422B37D47600844320 /* NdbExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbExtensions.swift; sourceTree = "<group>"; usesTabs = 0; }; + B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteItem.swift; sourceTree = "<group>"; usesTabs = 0; }; + B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusDuration.swift; sourceTree = "<group>"; usesTabs = 0; }; BA3759892ABCCDE30018D73B /* ImageResizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResizer.swift; sourceTree = "<group>"; }; BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCaptureProcessor.swift; sourceTree = "<group>"; }; BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCaptureProcessor.swift; sourceTree = "<group>"; }; @@ -1587,6 +1594,7 @@ D7EDED1D2B11797D0018B19C /* LongformEvent.swift */, D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */, D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */, + B5C60C1F2B530D5100C5ECA7 /* MuteItem.swift */, ); path = Models; sourceTree = "<group>"; @@ -2290,6 +2298,7 @@ 4CC14FED2A73FCBB007AEB17 /* Ids */, 7527271D2A93FF0100214108 /* Block.swift */, D798D21D2B0858BB00234419 /* MigratedTypes.swift */, + B5C60C222B532A8700C5ECA7 /* DamusDuration.swift */, ); path = Types; sourceTree = "<group>"; @@ -2674,6 +2683,7 @@ children = ( F944F56D29EA9CCC0067B3BF /* DamusParseContentTests.swift */, 75AD872A2AA23A460085EF2C /* Block+Tests.swift */, + B5A75C292B546D94007AFBC0 /* MuteItemTests.swift */, ); path = Models; sourceTree = "<group>"; @@ -2937,6 +2947,7 @@ ADFE73552AD4793100EC7326 /* QRScanNSECView.swift in Sources */, 4C3AC79D2833036D00E1F516 /* FollowingView.swift in Sources */, 5CF72FC229B9142F00124A13 /* ShareAction.swift in Sources */, + B5C60C232B532A8700C5ECA7 /* DamusDuration.swift in Sources */, 4C32B9522A9AD44700DC3548 /* Message.swift in Sources */, 4C8D1A6C29F1DFC200ACDF75 /* FriendIcon.swift in Sources */, 4C30AC7829A577AB00E2BD5A /* EventCache.swift in Sources */, @@ -3045,6 +3056,7 @@ 4C7D09602A098C5D00943473 /* WalletView.swift in Sources */, 4CB8838F296F781C00DC99E7 /* ReactionsView.swift in Sources */, BA4AB0B02A63B94D0070A32A /* EmojiListItemView.swift in Sources */, + B5C60C202B530D5100C5ECA7 /* MuteItem.swift in Sources */, 4C75EFB328049D640006080F /* NostrEvent.swift in Sources */, 4C32B9582A9AD44700DC3548 /* VeriferOptions.swift in Sources */, D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */, @@ -3379,6 +3391,7 @@ B5B4D1432B37D47600844320 /* NdbExtensions.swift in Sources */, 3ACBCB78295FE5C70037388A /* TimeAgoTests.swift in Sources */, D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */, + B5A75C2A2B546D94007AFBC0 /* MuteItemTests.swift in Sources */, 4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */, D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */, 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, @@ -3540,6 +3553,7 @@ D7CB5D472B11718700AD4105 /* Wallet.swift in Sources */, D7CE1B412B0BE719002EDAD4 /* FlatBuffersUtils.swift in Sources */, D7CB5D482B11719300AD4105 /* Profiles.swift in Sources */, + B5C60C212B530D5600C5ECA7 /* MuteItem.swift in Sources */, D798D2262B085C4200234419 /* Bech32.swift in Sources */, D7CE1B482B0BE719002EDAD4 /* Message.swift in Sources */, D7CB5D462B11703D00AD4105 /* Notify.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -1116,7 +1116,7 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> result(.event(ev)) } case .hashtag(let ht): - result(.filter(.filter_hashtag([ht.string()]))) + result(.filter(.filter_hashtag([ht.hashtag]))) case .param, .quote: // doesn't really make sense here break diff --git a/damus/Models/Contacts+.swift b/damus/Models/Contacts+.swift @@ -109,7 +109,7 @@ func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool { return contacts.references.contains { ref in switch (ref, follow) { case let (.hashtag(ht), .hashtag(follow_ht)): - return ht.string() == follow_ht + return ht.hashtag == follow_ht case let (.pubkey(pk), .pubkey(follow_pk)): return pk == follow_pk case (.hashtag, .pubkey), (.pubkey, .hashtag), diff --git a/damus/Models/MuteItem.swift b/damus/Models/MuteItem.swift @@ -0,0 +1,208 @@ +// +// MuteItem.swift +// damus +// +// Created by Charlie Fish on 1/13/24. +// + +import Foundation + +/// Represents an item that is muted. +enum MuteItem: Hashable, Equatable { + /// A user that is muted. + /// + /// The associated type is the ``Pubkey`` that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires. + case user(Pubkey, Date?) + + /// A hashtag that is muted. + /// + /// The associated type is the hashtag string that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires. + case hashtag(Hashtag, Date?) + + /// A word/phrase that is muted. + /// + /// The associated type is the word/phrase that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires. + case word(String, Date?) + + /// A thread that is muted. + /// + /// The associated type is the `id` of the note that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires. + case thread(NoteId, Date?) + + func is_expired() -> Bool { + switch self { + case .user(_, let expiration_date): + return expiration_date ?? .distantFuture < Date() + case .hashtag(_, let expiration_date): + return expiration_date ?? .distantFuture < Date() + case .word(_, let expiration_date): + return expiration_date ?? .distantFuture < Date() + case .thread(_, let expiration_date): + return expiration_date ?? .distantFuture < Date() + } + } + + static func == (lhs: MuteItem, rhs: MuteItem) -> Bool { + // lhs is the item we want to check (ie. the item the user is attempting to display) + // rhs is the item we want to check against (ie. the item in the mute list) + + switch (lhs, rhs) { + case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, let rhs_expiration_date)): + return lhs_pubkey == rhs_pubkey && !rhs.is_expired() + case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, let rhs_expiration_date)): + return lhs_hashtag == rhs_hashtag && !rhs.is_expired() + case (.word(let lhs_word, _), .word(let rhs_word, let rhs_expiration_date)): + return lhs_word == rhs_word && !rhs.is_expired() + case (.thread(let lhs_thread, _), .thread(let rhs_thread, let rhs_expiration_date)): + return lhs_thread == rhs_thread && !rhs.is_expired() + default: + return false + } + } + + private var refTags: [String] { + switch self { + case .user(let pubkey, _): + return RefId.pubkey(pubkey).tag + case .hashtag(let hashtag, _): + return RefId.hashtag(hashtag).tag + case .word(let string, _): + return ["word", string] + case .thread(let noteId, _): + return RefId.event(noteId).tag + } + } + + var tag: [String] { + var tag = self.refTags + + switch self { + case .user(_, let date): + if let date { + tag.append("\(Int(date.timeIntervalSince1970))") + } + case .hashtag(_, let date): + if let date { + tag.append("\(Int(date.timeIntervalSince1970))") + } + case .word(_, let date): + if let date { + tag.append("\(Int(date.timeIntervalSince1970))") + } + case .thread(_, let date): + if let date { + tag.append("\(Int(date.timeIntervalSince1970))") + } + } + + return tag + } + + var title: String { + switch self { + case .user: + return "user" + case .hashtag: + return "hashtag" + case .word: + return "word" + case .thread: + return "thread" + } + } + + init?(_ tag: [String]) { + guard let tag_id = tag.first else { return nil } + guard let tag_content = tag[safe: 1] else { return nil } + + let tag_expiration_date: Date? = { + if let tag_expiration_string: String = tag[safe: 2], + let tag_expiration_number: TimeInterval = Double(tag_expiration_string) { + return Date(timeIntervalSince1970: tag_expiration_number) + } else { + return nil + } + }() + + switch tag_id { + case "p": + guard let pubkey = Pubkey(hex: tag_content) else { return nil } + self = MuteItem.user(pubkey, tag_expiration_date) + break + case "t": + self = MuteItem.hashtag(Hashtag(hashtag: tag_content), tag_expiration_date) + break + case "word": + self = MuteItem.word(tag_content, tag_expiration_date) + break + case "thread": + guard let note_id = NoteId(hex: tag_content) else { return nil } + self = MuteItem.thread(note_id, tag_expiration_date) + break + default: + return nil + } + } +} + +extension Collection where Element == MuteItem { + /// Check if an event is muted given a collection of ``MutedItem``. + /// + /// - Parameter ev: The ``NostrEvent`` that you want to check the muted reason for. + /// - Returns: The ``MuteItem`` that matched the event. Or `nil` if the event is not muted. + func event_muted_reason(_ ev: NostrEvent) -> MuteItem? { + return self.first { muted_item in + switch muted_item { + case .user(let pubkey, let expiration_date): + return pubkey == ev.pubkey && !muted_item.is_expired() + case .hashtag(let hashtag, let expiration_date): + return ev.referenced_hashtags.contains(hashtag) && !muted_item.is_expired() + case .word(let word, let expiration_date): + return ev.content.lowercased().contains(word.lowercased()) && !muted_item.is_expired() + case .thread(let note_id, let expiration_date): + return ev.referenced_ids.contains(note_id) && !muted_item.is_expired() + } + } + } + + var users: [Pubkey] { + return self.compactMap { muted_item in + if case .user(let pubkey, _) = muted_item, + !muted_item.is_expired() { + return pubkey + } else { + return nil + } + } + } + var hashtags: [Hashtag] { + return self.compactMap { muted_item in + if case .hashtag(let hashtag, _) = muted_item, + !muted_item.is_expired() { + return hashtag + } else { + return nil + } + } + } + var words: [String] { + return self.compactMap { muted_item in + if case .word(let str, _) = muted_item, + !muted_item.is_expired() { + return str + } else { + return nil + } + } + } + var threads: [NoteId] { + return self.compactMap { muted_item in + if case .thread(let note_id, _) = muted_item, + !muted_item.is_expired() { + return note_id + } else { + return nil + } + } + } +} diff --git a/damus/Nostr/ReferencedId.swift b/damus/Nostr/ReferencedId.swift @@ -119,7 +119,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable { case event(NoteId) case pubkey(Pubkey) case quote(QuoteId) - case hashtag(TagElem) + case hashtag(Hashtag) case param(TagElem) case naddr(NAddr) @@ -155,7 +155,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable { case .event(let noteId): return noteId.hex() case .pubkey(let pubkey): return pubkey.hex() case .quote(let quote): return quote.hex() - case .hashtag(let string): return string.string() + case .hashtag(let string): return string.hashtag case .param(let string): return string.string() case .naddr(let naddr): return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier @@ -176,7 +176,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable { case .e: return t1.id().map({ .event(NoteId($0)) }) case .p: return t1.id().map({ .pubkey(Pubkey($0)) }) case .q: return t1.id().map({ .quote(QuoteId($0)) }) - case .t: return .hashtag(t1) + case .t: return .hashtag(Hashtag(hashtag: t1.string())) case .d: return .param(t1) case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0)) } diff --git a/damus/Types/DamusDuration.swift b/damus/Types/DamusDuration.swift @@ -0,0 +1,38 @@ +// +// DamusDuration.swift +// damus +// +// Created by Charlie Fish on 1/13/24. +// + +import Foundation + +enum DamusDuration: CaseIterable { + case day + case week + case month + + var title: String { + switch self { + case .day: + return NSLocalizedString("24 hours", comment: "A duration of 24 hours/1 day to be shown to the user. Most likely in the context of how long they want to mute a piece of content for.") + case .week: + return NSLocalizedString("1 week", comment: "A duration of 1 week to be shown to the user. Most likely in the context of how long they want to mute a piece of content for.") + case .month: + return NSLocalizedString("1 month", comment: "A duration of 1 month to be shown to the user. Most likely in the context of how long they want to mute a piece of content for.") + } + } + + var date_from_now: Date? { + let current_date = Date() + + switch self { + case .day: + return Calendar.current.date(byAdding: .day, value: 1, to: current_date) + case .week: + return Calendar.current.date(byAdding: .day, value: 7, to: current_date) + case .month: + return Calendar.current.date(byAdding: .month, value: 1, to: current_date) + } + } +} diff --git a/damusTests/Models/MuteItemTests.swift b/damusTests/Models/MuteItemTests.swift @@ -0,0 +1,58 @@ +// +// MuteItemTests.swift +// damusTests +// +// Created by Charlie Fish on 1/14/24. +// + +import XCTest +@testable import damus + +class MuteItemTests: 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. + } + + // MARK: - `is_expired` + func test_hashtag_is_expired() throws { + XCTAssertTrue(MuteItem.hashtag(Hashtag(hashtag: "test"), Date(timeIntervalSince1970: 0)).is_expired()) + XCTAssertTrue(MuteItem.hashtag(Hashtag(hashtag: "test"), .distantPast).is_expired()) + XCTAssertFalse(MuteItem.hashtag(Hashtag(hashtag: "test"), .distantFuture).is_expired()) + } + func test_user_is_expired() throws { + XCTAssertTrue(MuteItem.user(test_pubkey, Date(timeIntervalSince1970: 0)).is_expired()) + XCTAssertTrue(MuteItem.user(test_pubkey, .distantPast).is_expired()) + XCTAssertFalse(MuteItem.user(test_pubkey, .distantFuture).is_expired()) + } + func test_word_is_expired() throws { + XCTAssertTrue(MuteItem.word("test", Date(timeIntervalSince1970: 0)).is_expired()) + XCTAssertTrue(MuteItem.word("test", .distantPast).is_expired()) + XCTAssertFalse(MuteItem.word("test", .distantFuture).is_expired()) + } + func test_thread_is_expired() throws { + XCTAssertTrue(MuteItem.thread(test_note.id, Date(timeIntervalSince1970: 0)).is_expired()) + XCTAssertTrue(MuteItem.thread(test_note.id, .distantPast).is_expired()) + XCTAssertFalse(MuteItem.thread(test_note.id, .distantFuture).is_expired()) + } + + + // MARK: - `tag` + func test_hashtag_tag() throws { + XCTAssertEqual(MuteItem.hashtag(Hashtag(hashtag: "test"), nil).tag, ["t", "test"]) + XCTAssertEqual(MuteItem.hashtag(Hashtag(hashtag: "test"), Date(timeIntervalSince1970: 1704067200)).tag, ["t", "test", "1704067200"]) + } + func test_user_tag() throws { + XCTAssertEqual(MuteItem.user(test_pubkey, Date(timeIntervalSince1970: 1704067200)).tag, ["p", test_pubkey.hex(), "1704067200"]) + } + func test_word_tag() throws { + XCTAssertEqual(MuteItem.word("test", Date(timeIntervalSince1970: 1704067200)).tag, ["word", "test", "1704067200"]) + } + func test_thread_tag() throws { + XCTAssertEqual(MuteItem.thread(test_note.id, Date(timeIntervalSince1970: 1704067200)).tag, ["e", test_note.id.hex(), "1704067200"]) + } +}