Skip to content

Commit

Permalink
add support for NIP-51 mute lists (#120)
Browse files Browse the repository at this point in the history
  • Loading branch information
bryanmontz authored Dec 17, 2023
1 parent 5dc43c5 commit 87cf347
Show file tree
Hide file tree
Showing 12 changed files with 316 additions and 18 deletions.
63 changes: 63 additions & 0 deletions Sources/NostrSDK/EventCreating.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,69 @@ public extension EventCreating {
return try ReportEvent(content: additionalInformation, tags: tags, signedBy: keypair)
}

/// Creates a ``MuteListEvent`` (kind 10000) containing things the user doesn't want to see in their feeds. Mute list items be publicly visible or private.
/// - Parameters:
/// - publiclyMutedPubkeys: Pubkeys to mute.
/// - privatelyMutedPubkeys: Pubkeys to secretly mute.
/// - publiclyMutedEventIds: Event ids to mute.
/// - privatelyMutedEventIds: Event ids to secretly mute.
/// - publiclyMutedHashtags: Hashtags to mute.
/// - privatelyMutedHashtags: Hashtags to secretly mute.
/// - publiclyMutedKeywords: Keywords to mute.
/// - privatelyMutedKeywords: Keywords to secretly mute.
/// - keypair: The Keypair to sign with.
/// - Returns: The signed ``MuteListEvent``.
func muteList(withPubliclyMutedPubkeys publiclyMutedPubkeys: [String] = [],
privatelyMutedPubkeys: [String] = [],
publiclyMutedEventIds: [String] = [],
privatelyMutedEventIds: [String] = [],
publiclyMutedHashtags: [String] = [],
privatelyMutedHashtags: [String] = [],
publiclyMutedKeywords: [String] = [],
privatelyMutedKeywords: [String] = [],
signedBy keypair: Keypair) throws -> MuteListEvent {
var publicTags = [Tag]()

for pubkey in publiclyMutedPubkeys {
publicTags.append(Tag(name: .pubkey, value: pubkey))
}
for eventId in publiclyMutedEventIds {
publicTags.append(Tag(name: .event, value: eventId))
}
for hashtag in publiclyMutedHashtags {
publicTags.append(Tag(name: .hashtag, value: hashtag))
}
for keyword in publiclyMutedKeywords {
publicTags.append(Tag(name: .word, value: keyword))
}

var secretTags = [[String]]()
for pubkey in privatelyMutedPubkeys {
secretTags.append([TagName.pubkey.rawValue, pubkey])
}
for eventId in privatelyMutedEventIds {
secretTags.append([TagName.event.rawValue, eventId])
}
for hashtag in privatelyMutedHashtags {
secretTags.append([TagName.hashtag.rawValue, hashtag])
}
for keyword in privatelyMutedKeywords {
secretTags.append([TagName.word.rawValue, keyword])
}

var encryptedContent: String?
if !secretTags.isEmpty {
if let unencryptedData = try? JSONSerialization.data(withJSONObject: secretTags),
let unencryptedContent = String(data: unencryptedData, encoding: .utf8) {
encryptedContent = try encrypt(content: unencryptedContent,
privateKey: keypair.privateKey,
publicKey: keypair.publicKey)
}
}

return try MuteListEvent(content: encryptedContent ?? "", tags: publicTags, signedBy: keypair)
}

/// Creates a ``LongformContentEvent`` (kind 30023, a parameterized replaceable event) for long-form text content, generally referred to as "articles" or "blog posts".
/// - Parameters:
/// - identifier: A unique identifier for the content. Can be reused in the future for replacing the event.
Expand Down
7 changes: 7 additions & 0 deletions Sources/NostrSDK/EventKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable {
/// See [NIP-56](https://github.com/nostr-protocol/nips/blob/b4cdc1a73d415c79c35655fa02f5e55cd1f2a60c/56.md#nip-56).
case report

/// This kind of event contains a list of things the user does not want to see, such as pubkeys, hashtags, words, and event ids (threads).
///
/// See [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists)
case muteList

/// This kind of event is for long-form texxt content, generally referred to as "articles" or "blog posts".
///
/// See [NIP-23](https://github.com/nostr-protocol/nips/blob/master/23.md).
Expand Down Expand Up @@ -96,6 +101,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable {
.reaction,
.genericRepost,
.report,
.muteList,
.longformContent,
.dateBasedCalendarEvent,
.timeBasedCalendarEvent
Expand All @@ -118,6 +124,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable {
case .reaction: return 7
case .genericRepost: return 16
case .report: return 1984
case .muteList: return 10000
case .longformContent: return 30023
case .dateBasedCalendarEvent: return 31922
case .timeBasedCalendarEvent: return 31923
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public final class DateBasedCalendarEvent: NostrEvent, CalendarEventInterpreting
/// Start date is represented by ``TimeOmittedDate``.
/// `nil` is returned if the backing `start` tag is malformed.
public var startDate: TimeOmittedDate? {
guard let startString = valueForRawTagName("start") else {
guard let startString = firstValueForRawTagName("start") else {
return nil
}

Expand All @@ -39,7 +39,7 @@ public final class DateBasedCalendarEvent: NostrEvent, CalendarEventInterpreting
/// End date represented by ``TimeOmittedDate``.
/// `nil` is returned if the backing `end` tag is malformed or if the calendar event ends on the same date as start.
public var endDate: TimeOmittedDate? {
guard let endString = valueForRawTagName("end") else {
guard let endString = firstValueForRawTagName("end") else {
return nil
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public final class TimeBasedCalendarEvent: NostrEvent, CalendarEventInterpreting
/// The start timestamp is represented by ``Date``.
/// `nil` is returned if the backing `start` tag is malformed.
public var startTimestamp: Date? {
guard let startString = valueForRawTagName("start"), let startSeconds = Int(startString) else {
guard let startString = firstValueForRawTagName("start"), let startSeconds = Int(startString) else {
return nil
}

Expand All @@ -37,7 +37,7 @@ public final class TimeBasedCalendarEvent: NostrEvent, CalendarEventInterpreting
/// End timestamp represented by ``Date``.
/// `nil` is returned if the backing `end` tag is malformed or if the calendar event ends instanteously.
public var endTimestamp: Date? {
guard let endString = valueForRawTagName("end"), let endSeconds = Int(endString) else {
guard let endString = firstValueForRawTagName("end"), let endSeconds = Int(endString) else {
return nil
}

Expand All @@ -46,7 +46,7 @@ public final class TimeBasedCalendarEvent: NostrEvent, CalendarEventInterpreting

/// The time zone of the start timestamp.
public var startTimeZone: TimeZone? {
guard let timeZoneIdentifier = valueForRawTagName("start_tzid") else {
guard let timeZoneIdentifier = firstValueForRawTagName("start_tzid") else {
return nil
}

Expand All @@ -56,7 +56,7 @@ public final class TimeBasedCalendarEvent: NostrEvent, CalendarEventInterpreting
/// The time zone of the end timestamp.
/// `nil` can be returned if the time zone identifier is malformed or if the time zone of the end timestamp is the same as the start timestamp.
public var endTimeZone: TimeZone? {
guard let timeZoneIdentifier = valueForRawTagName("end_tzid") else {
guard let timeZoneIdentifier = firstValueForRawTagName("end_tzid") else {
return nil
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/NostrSDK/Events/GenericRepostEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public class GenericRepostEvent: NostrEvent {

/// The pubkey of the reposted event.
var repostedEventPubkey: String? {
valueForTagName(.pubkey)
firstValueForTagName(.pubkey)
}

/// The note that is being reposted.
Expand All @@ -45,7 +45,7 @@ public class GenericRepostEvent: NostrEvent {

/// The id of the event that is being reposted.
var repostedEventId: String? {
valueForTagName(.event)
firstValueForTagName(.event)
}

/// The relay URL at which to fetch the reposted event.
Expand Down
10 changes: 5 additions & 5 deletions Sources/NostrSDK/Events/LongformContentEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public final class LongformContentEvent: NostrEvent, HashtagInterpreting {

/// The date of the first time the article was published.
var publishedAt: Date? {
guard let unixTimeString = valueForTagName(.publishedAt),
guard let unixTimeString = firstValueForTagName(.publishedAt),
let unixSeconds = TimeInterval(unixTimeString) else {
return nil
}
Expand All @@ -39,22 +39,22 @@ public final class LongformContentEvent: NostrEvent, HashtagInterpreting {

/// A unique identifier for the content. Can be reused in the future for replacing the event.
var identifier: String? {
valueForTagName(.identifier)
firstValueForTagName(.identifier)
}

/// The article title.
var title: String? {
valueForTagName(.title)
firstValueForTagName(.title)
}

/// A summary of the content.
var summary: String? {
valueForTagName(.summary)
firstValueForTagName(.summary)
}

/// A URL pointing to an image to be shown along with the title.
var imageURL: URL? {
guard let imageURLString = valueForTagName(.image) else {
guard let imageURLString = firstValueForTagName(.image) else {
return nil
}
return URL(string: imageURLString)
Expand Down
78 changes: 78 additions & 0 deletions Sources/NostrSDK/Events/MuteListEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// MuteListEvent.swift
//
//
// Created by Bryan Montz on 12/15/23.
//

import Foundation

/// An event that contains various things the user doesn't want to see in their feeds.
///
/// See [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists).
public final class MuteListEvent: NostrEvent, HashtagInterpreting, DirectMessageEncrypting {
public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}

@available(*, unavailable, message: "This initializer is unavailable for this class.")
override init(kind: EventKind, content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws {
try super.init(kind: kind, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

init(content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws {
try super.init(kind: .muteList, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

/// The publicly muted public keys (authors).
public var pubkeys: [String] {
allValues(forTagName: .pubkey) ?? []
}

/// The publicly muted event ids (threads).
public var eventIds: [String] {
allValues(forTagName: .event) ?? []
}

/// The publicly muted keywords.
public var keywords: [String] {
allValues(forTagName: .word) ?? []
}

/// The privately muted public keys (authors).
public func privatePubkeys(using keypair: Keypair) -> [String] {
privateTags(withName: .pubkey, using: keypair)
}

/// The privately muted event ids (threads).
public func privateEventIds(using keypair: Keypair) -> [String] {
privateTags(withName: .event, using: keypair)
}

/// The privately muted hashtags.
public func privateHashtags(using keypair: Keypair) -> [String] {
privateTags(withName: .hashtag, using: keypair)
}

/// The privately muted keywords.
public func privateKeywords(using keypair: Keypair) -> [String] {
privateTags(withName: .word, using: keypair)
}

private func privateTags(withName tagName: TagName, using keypair: Keypair) -> [String] {
privateTags(using: keypair).filter { $0.name == tagName.rawValue }.map { $0.value }
}

/// The private tags encrypted in the content of the event.
/// - Parameter keypair: The keypair to use to decrypt the content.
/// - Returns: The private tags.
func privateTags(using keypair: Keypair) -> [Tag] {
guard let decryptedContent = try? decrypt(encryptedContent: content, privateKey: keypair.privateKey, publicKey: keypair.publicKey),
let jsonData = decryptedContent.data(using: .utf8) else {
return []
}

let tags = try? JSONDecoder().decode([Tag].self, from: jsonData)
return tags ?? []
}
}
17 changes: 12 additions & 5 deletions Sources/NostrSDK/Events/NostrEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,20 @@ public class NostrEvent: Codable {
content: content)
}

/// the String value for the provided ``TagName``, if it exists
public func valueForTagName(_ tag: TagName) -> String? {
valueForRawTagName(tag.rawValue)
/// the first String value for the provided ``TagName``, if it exists
public func firstValueForTagName(_ tag: TagName) -> String? {
firstValueForRawTagName(tag.rawValue)
}

/// the String value for the provided raw tag name, if it exists
public func valueForRawTagName(_ tagName: String) -> String? {
/// the first String value for the provided raw tag name, if it exists
public func firstValueForRawTagName(_ tagName: String) -> String? {
tags.first(where: { $0.name == tagName })?.value
}

/// All tags with the provided name.
/// - Parameter tag: The tag name to filter.
/// - Returns: The values associated with the tags of the provided name.
public func allValues(forTagName tag: TagName) -> [String]? {
tags.filter { $0.name == tag.rawValue }.map { $0.value }
}
}
9 changes: 9 additions & 0 deletions Sources/NostrSDK/Tag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public enum TagName: String {

/// a web URL the event is referring to in some way. See [NIP-24 - Extra metadata fields and tags](https://github.com/nostr-protocol/nips/blob/master/24.md#tags).
case webURL = "r"

/// a keyword to mute
case word
}

/// A constant that describes a type of reference to an event.
Expand Down Expand Up @@ -165,3 +168,9 @@ public class Tag: Codable, Equatable {
otherParameters == tag.otherParameters
}
}

extension Tag: CustomDebugStringConvertible {
public var debugDescription: String {
"Tag(name: \"\(name)\", value: \"\(value)\")"
}
}
Loading

0 comments on commit 87cf347

Please sign in to comment.