Skip to content

Commit

Permalink
Refactor threaded event tag building and interpreting logic so non-ki…
Browse files Browse the repository at this point in the history
…nd-1 events can reuse them
  • Loading branch information
tyiu committed Oct 18, 2024
1 parent d8ada06 commit 254d823
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 142 deletions.
161 changes: 155 additions & 6 deletions Sources/NostrSDK/Events/Tags/EventTag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,160 @@ public struct EventTag: RelayProviding, RelayURLValidating, Equatable {
}
}

public protocol EventTagInterpreting: NostrEvent {}
public extension EventTagInterpreting {
/// The event tags assigned to this ``NostrEvent``.
var eventTags: [EventTag] {
tags.filter { $0.name == TagName.event.rawValue }
.compactMap { EventTag(tag: $0) }
/// Interprets threaded tags on events.
/// See [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md).
public protocol ThreadedEventTagInterpreting: NostrEvent {}
public extension ThreadedEventTagInterpreting {
/// The ``EventTag`` that denotes the reply event being responded to.
/// This event tag may be the same as ``rootEventTag`` if this event is a direct reply to the root of a thread.
/// See [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md).
var replyEventTag: EventTag? {
let eventTags = tags.compactMap { EventTag(tag: $0) }

// Return the first event tag with a reply marker if it exists.
if let reply = eventTags.first(where: { $0.marker == .reply }) {
return reply
}

// A direct reply to the root of a thread should have a single marked event tag of type "root".
if let root = eventTags.first(where: { $0.marker == .root }) {
return root
}

// If there are no reply or root event markers, and there is at least one event tag with a marker,
// then we can make a reasonable assumption that the client that created the event does not use
// deprecated positional event tags, so there is no reply event tag.
guard eventTags.allSatisfy({ $0.marker == nil }) else {
return nil
}

// Otherwise, NIP-10 states that the last event tag is the one being responded to.
return eventTags.last
}

/// The ``EventTag`` that denotes the root event of the thread being responded to.
/// See [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md).
var rootEventTag: EventTag? {
let eventTags = tags.compactMap { EventTag(tag: $0) }

// Return the first event tag with a root marker if it exists.
if let root = eventTags.first(where: { $0.marker == .root }) {
return root
}

// If there are no root event markers, and there is at least one event tag with a marker,
// then we can make a reasonable assumption that the client that created the event does not use
// deprecated positional event tags, so there is no root event tag.
guard eventTags.allSatisfy({ $0.marker == nil }) else {
return nil
}

// NIP-10 states that the first event tag is the root.
return eventTags.first
}

/// The ``EventTag``s that denotes quoted or reposted events.
/// See [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md).
var mentionedEventTags: [EventTag] {
let eventTags = tags.compactMap { EventTag(tag: $0) }

// Only mention markers are considered mentions in the preferred spec.
// If there is a mix of mention markers and no markers, the event tags
// with no markers are ignored.
let mentionedEventTags = eventTags.filter { $0.marker == .mention }

if !mentionedEventTags.isEmpty {
return mentionedEventTags
}

// If the event has any event tags with any marker, then we can make a reasonable assummption
// that the client that created this event does not use deprecated positional event tags,
// so there are no mentions.
//
// Even if there are no event tag markers, the deprecated positional event tag spec in NIP-10
// states that there are no mentions unless there are 3 or more event tags.
guard eventTags.allSatisfy({ $0.marker == nil }) && eventTags.count >= 3 else {
return []
}

// The first event tag is the root and the last event tag is the one being replied to.
// Everything else in between is a mention.
return eventTags.dropFirst().dropLast()
}
}

/// Builds tags on a threaded event.
/// See [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md).
public protocol ThreadedEventTagBuilding: NostrEventBuilding, RelayURLValidating {}
public extension ThreadedEventTagBuilding {
/// Sets the ``ThreadedEventTagInterpreting`` event that is being replied to from this event that is being built.
/// See [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md).
@discardableResult
func repliedEvent(_ repliedEvent: ThreadedEventTagInterpreting, relayURL: URL? = nil) throws -> Self {
let validatedRelayURL: URL?
if let relayURL {
validatedRelayURL = try validateRelayURL(relayURL)
} else {
validatedRelayURL = nil
}

if let rootEventTag = repliedEvent.rootEventTag {
// Maximize backwards compatibility with NIP-10 deprecated positional event tags
// by ensuring ordering of types of event tags.

// Root tag comes first.
if rootEventTag.marker == .root {
insertTags(rootEventTag.tag, at: 0)
} else {
// Recreate the event tag with a root marker if the one being read does not have a marker.
let rootEventTagWithMarker = try EventTag(eventId: rootEventTag.eventId, relayURL: rootEventTag.relayURL, marker: .root, pubkey: rootEventTag.pubkey)
insertTags(rootEventTagWithMarker.tag, at: 0)
}

// Reply tag comes last.
appendTags(try EventTag(eventId: repliedEvent.id, relayURL: validatedRelayURL, marker: .reply, pubkey: repliedEvent.pubkey).tag)
} else {
// If the event being replied to has no root marker event tag,
// the event being replied to is the root.
insertTags(try EventTag(eventId: repliedEvent.id, relayURL: validatedRelayURL, marker: .root, pubkey: repliedEvent.pubkey).tag, at: 0)
}

// When replying to a text event E, the reply event's "p" tags should contain all of E's "p" tags as well as the "pubkey" of the event being replied to.
// Example: Given a text event authored by a1 with "p" tags [p1, p2, p3] then the "p" tags of the reply should be [a1, p1, p2, p3] in no particular order.
appendTags(contentsOf: repliedEvent.tags.filter { $0.name == TagName.pubkey.rawValue })

// Add the author "p" tag if it was not already added.
if !tags.contains(where: { $0.name == TagName.pubkey.rawValue && $0.value == repliedEvent.pubkey }) {
if let validatedRelayURL {
appendTags(Tag(name: .pubkey, value: repliedEvent.pubkey, otherParameters: [validatedRelayURL.absoluteString]))
} else {
appendTags(Tag(name: .pubkey, value: repliedEvent.pubkey))
}
}

return self
}

/// Sets the list of events, represented by ``EventTag``, that are mentioned from this event that is being built.
/// See [NIP-10](https://github.com/nostr-protocol/nips/blob/master/10.md).
@discardableResult
func mentionedEventTags(_ mentionedEventTags: [EventTag]) throws -> Self {
guard !mentionedEventTags.isEmpty else {
return self
}

guard mentionedEventTags.allSatisfy({ $0.marker == .mention }) else {
throw EventCreatingError.invalidInput
}

let newTags = mentionedEventTags.map { $0.tag }
// Mentions go in between root markers and reply markers.
if let replyMarkerIndex = tags.firstIndex(where: { $0.otherParameters.count >= 2 && $0.otherParameters[1] == EventTagMarker.reply.rawValue }) {
insertTags(contentsOf: newTags, at: replyMarkerIndex)
} else {
appendTags(contentsOf: newTags)
}

return self
}
}
150 changes: 15 additions & 135 deletions Sources/NostrSDK/Events/TextNoteEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import Foundation
/// An event that contains plaintext content.
///
/// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/b503f8a92b22be3037b8115fe3e644865a4fa155/01.md#basic-event-kinds)
public final class TextNoteEvent: NostrEvent, CustomEmojiInterpreting {
public final class TextNoteEvent: NostrEvent, CustomEmojiInterpreting, ThreadedEventTagInterpreting {

public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}
Expand All @@ -37,87 +37,25 @@ public final class TextNoteEvent: NostrEvent, CustomEmojiInterpreting {
}

/// Pubkeys mentioned in the note content.
@available(*, deprecated, message: "Deprecated in favor of referencedPubkeys. Mention is an overloaded term from NIP-10.")
public var mentionedPubkeys: [String] {
allValues(forTagName: .pubkey)
referencedPubkeys
}

/// Events mentioned in the note content.
public var mentionedEventIds: [String] {
allValues(forTagName: .event)
}

/// The ``EventTag`` that denotes the reply event being responded to.
/// This event tag may be the same as ``rootEventTag`` if this note is a direct reply to the root of a thread.
public var replyEventTag: EventTag? {
let eventTags = tags.compactMap { EventTag(tag: $0) }

// Return the first event tag with a reply marker if it exists.
if let reply = eventTags.first(where: { $0.marker == .reply }) {
return reply
}

// A direct reply to the root of a thread should have a single marked event tag of type "root".
if let root = eventTags.first(where: { $0.marker == .root }) {
return root
}

// If there are no reply or root event markers, and there is at least one event tag with a marker,
// then we can make a reasonable assumption that the client that created the event does not use
// deprecated positional event tags, so there is no reply event tag.
guard eventTags.allSatisfy({ $0.marker == nil }) else {
return nil
}

// Otherwise, NIP-10 states that the last event tag is the one being responded to.
return eventTags.last
/// Pubkeys mentioned in the note content.
public var referencedPubkeys: [String] {
allValues(forTagName: .pubkey)
}

/// The ``EventTag`` that denotes the root event of the thread being responded to.
public var rootEventTag: EventTag? {
let eventTags = tags.compactMap { EventTag(tag: $0) }

// Return the first event tag with a root marker if it exists.
if let root = eventTags.first(where: { $0.marker == .root }) {
return root
}

// If there are no root event markers, and there is at least one event tag with a marker,
// then we can make a reasonable assumption that the client that created the event does not use
// deprecated positional event tags, so there is no root event tag.
guard eventTags.allSatisfy({ $0.marker == nil }) else {
return nil
}

// NIP-10 states that the first event tag is the root.
return eventTags.first
/// Events mentioned in the note content.
@available(*, deprecated, message: "Deprecated in favor of referencedEventIds. Mention is an overloaded term from NIP-10.")
public var mentionedEventIds: [String] {
referencedEventIds
}

/// The ``EventTag``s that denotes quoted or reposted events.
public var mentionedEventTags: [EventTag] {
let eventTags = tags.compactMap { EventTag(tag: $0) }

// Only mention markers are considered mentions in the preferred spec.
// If there is a mix of mention markers and no markers, the event tags
// with no markers are ignored.
let mentionedEventTags = eventTags.filter { $0.marker == .mention }

if !mentionedEventTags.isEmpty {
return mentionedEventTags
}

// If the event has any event tags with any marker, then we can make a reasonable assummption
// that the client that created this event does not use deprecated positional event tags,
// so there are no mentions.
//
// Even if there are no event tag markers, the deprecated positional event tag spec in NIP-10
// states that there are no mentions unless there are 3 or more event tags.
guard eventTags.allSatisfy({ $0.marker == nil }) && eventTags.count >= 3 else {
return []
}

// The first event tag is the root and the last event tag is the one being replied to.
// Everything else in between is a mention.
return eventTags.dropFirst().dropLast()
/// Events mentioned in the note content.
public var referencedEventIds: [String] {
allValues(forTagName: .event)
}

/// a short subject for a text note, similar to subjects in emails.
Expand Down Expand Up @@ -167,69 +105,11 @@ public extension EventCreating {

public extension TextNoteEvent {
/// Builder of a ``TextNoteEvent``.
final class Builder: NostrEvent.Builder<TextNoteEvent>, CustomEmojiBuilding {
final class Builder: NostrEvent.Builder<TextNoteEvent>, CustomEmojiBuilding, ThreadedEventTagBuilding {
public init() {
super.init(kind: .textNote)
}

/// Sets the ``TextNoteEvent`` that is being replied to from this text note that is being built.
@discardableResult
public final func repliedEvent(_ repliedEvent: TextNoteEvent, relayURL: URL? = nil) throws -> Self {
if let rootEventTag = repliedEvent.rootEventTag {
// Maximize backwards compatibility with NIP-10 deprecated positional event tags
// by ensuring ordering of types of event tags.

// Root tag comes first.
if rootEventTag.marker == .root {
insertTags(rootEventTag.tag, at: 0)
} else {
// Recreate the event tag with a root marker if the one being read does not have a marker.
let rootEventTagWithMarker = try EventTag(eventId: rootEventTag.eventId, relayURL: rootEventTag.relayURL, marker: .root, pubkey: rootEventTag.pubkey)
insertTags(rootEventTagWithMarker.tag, at: 0)
}

// Reply tag comes last.
appendTags(try EventTag(eventId: repliedEvent.id, relayURL: relayURL, marker: .reply, pubkey: repliedEvent.pubkey).tag)
} else {
// If the event being replied to has no root marker event tag,
// the event being replied to is the root.
insertTags(try EventTag(eventId: repliedEvent.id, relayURL: relayURL, marker: .root, pubkey: repliedEvent.pubkey).tag, at: 0)
}

// When replying to a text event E, the reply event's "p" tags should contain all of E's "p" tags as well as the "pubkey" of the event being replied to.
// Example: Given a text event authored by a1 with "p" tags [p1, p2, p3] then the "p" tags of the reply should be [a1, p1, p2, p3] in no particular order.
appendTags(contentsOf: repliedEvent.tags.filter { $0.name == TagName.pubkey.rawValue })

// Add the author "p" tag if it was not already added.
if !tags.contains(where: { $0.name == TagName.pubkey.rawValue && $0.value == repliedEvent.pubkey }) {
appendTags(Tag(name: .pubkey, value: repliedEvent.pubkey))
}

return self
}

/// Sets the list of events, represented by ``EventTag``, that are mentioned from this text note that is being built.
@discardableResult
public final func mentionedEventTags(_ mentionedEventTags: [EventTag]) throws -> Builder {
guard !mentionedEventTags.isEmpty else {
return self
}

guard mentionedEventTags.allSatisfy({ $0.marker == .mention }) else {
throw EventCreatingError.invalidInput
}

let newTags = mentionedEventTags.map { $0.tag }
// Mentions go in between root markers and reply markers.
if let replyMarkerIndex = tags.firstIndex(where: { $0.otherParameters.count >= 2 && $0.otherParameters[1] == EventTagMarker.reply.rawValue }) {
insertTags(contentsOf: newTags, at: replyMarkerIndex)
} else {
appendTags(contentsOf: newTags)
}

return self
}

/// Sets the subject for this text note.
@discardableResult
public final func subject(_ subject: String?) -> Builder {
Expand Down
2 changes: 1 addition & 1 deletion Tests/NostrSDKTests/Events/TextNoteEventTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ final class TextNoteEventTests: XCTestCase, EventCreating, EventVerifying, Fixtu

let expectedTags: [Tag] = [
rootEventTag.tag,
.pubkey(Keypair.test.publicKey.hex),
.pubkey(Keypair.test.publicKey.hex, otherParameters: ["wss://relay.damus.io"]),
mentionedEventTag1.tag,
mentionedEventTag2.tag
]
Expand Down

0 comments on commit 254d823

Please sign in to comment.