From 4d2fe32be79643cee6f165a10d25b2b379e1f13a Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Sat, 16 Dec 2023 10:39:22 -0500 Subject: [PATCH] Amend NIP-52 to support calendars and RSVPs --- Sources/NostrSDK/EventCreating.swift | 94 ++++++++-- Sources/NostrSDK/EventKind.swift | 20 ++- .../Calendars/CalendarEventInterpreting.swift | 9 +- .../Events/Calendars/CalendarEventRSVP.swift | 49 +++++ .../Calendars/CalendarEventRSVPFreebusy.swift | 41 +++++ .../Calendars/CalendarEventRSVPStatus.swift | 46 +++++ .../Events/Calendars/CalendarNostrEvent.swift | 29 +++ ...ordinates.swift => EventCoordinates.swift} | 12 +- .../Tags/IdentifierTagInterpreting.swift | 12 ++ Sources/NostrSDK/Tag.swift | 8 +- ...ests.swift => EventCoordinatesTests.swift} | 42 ++--- Tests/NostrSDKTests/EventCreatingTests.swift | 170 ++++++++++++++++-- Tests/NostrSDKTests/EventDecodingTests.swift | 109 ++++++++++- Tests/NostrSDKTests/Fixtures/calendar.json | 26 +++ .../Fixtures/calendar_event_rsvp.json | 36 ++++ .../Fixtures/date_based_calendar_event.json | 16 +- .../date_based_calendar_event_deprecated.json | 66 +++++++ .../Fixtures/time_based_calendar_event.json | 12 +- .../time_based_calendar_event_deprecated.json | 70 ++++++++ 19 files changed, 781 insertions(+), 86 deletions(-) create mode 100644 Sources/NostrSDK/Events/Calendars/CalendarEventRSVP.swift create mode 100644 Sources/NostrSDK/Events/Calendars/CalendarEventRSVPFreebusy.swift create mode 100644 Sources/NostrSDK/Events/Calendars/CalendarEventRSVPStatus.swift create mode 100644 Sources/NostrSDK/Events/Calendars/CalendarNostrEvent.swift rename Sources/NostrSDK/Events/Tags/{ReplaceableEventCoordinates.swift => EventCoordinates.swift} (90%) rename Tests/NostrSDKTests/{ReplaceableEventCoordinatesTests.swift => EventCoordinatesTests.swift} (50%) create mode 100644 Tests/NostrSDKTests/Fixtures/calendar.json create mode 100644 Tests/NostrSDKTests/Fixtures/calendar_event_rsvp.json create mode 100644 Tests/NostrSDKTests/Fixtures/date_based_calendar_event_deprecated.json create mode 100644 Tests/NostrSDKTests/Fixtures/time_based_calendar_event_deprecated.json diff --git a/Sources/NostrSDK/EventCreating.swift b/Sources/NostrSDK/EventCreating.swift index 9b13e1a..4c0b54a 100644 --- a/Sources/NostrSDK/EventCreating.swift +++ b/Sources/NostrSDK/EventCreating.swift @@ -293,7 +293,7 @@ public extension EventCreating { /// 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. + /// - identifier: A unique identifier for the content. Can be reused in the future for replacing the event. If an identifier is not provided, a ``UUID`` string is used. /// - title: The article title. /// - markdownContent: A string text in Markdown syntax. /// - summary: A summary of the content. @@ -302,7 +302,7 @@ public extension EventCreating { /// - publishedAt: The date of the first time the article was published. /// - keypair: The ``Keypair`` to sign with. /// - Returns: The signed ``LongformContentEvent``. - func longformContentEvent(withIdentifier identifier: String, + func longformContentEvent(withIdentifier identifier: String = UUID().uuidString, title: String? = nil, markdownContent: String, summary: String? = nil, @@ -340,11 +340,12 @@ public extension EventCreating { /// Creates a ``DateBasedCalendarEvent`` (kind 31922) which starts on a date and ends before a different date in the future. /// Its use is appropriate for all-day or multi-day events where time and time zone hold no significance. e.g., anniversary, public holidays, vacation days. /// - Parameters: - /// - name: The name of the calendar event. + /// - identifier: A unique identifier for the calendar event. Can be reused in the future for replacing the calendar event. If an identifier is not provided, a ``UUID`` string is used. + /// - title: The title of the calendar event. /// - description: A detailed description of the calendar event. /// - startDate: An inclusive start date. Must be less than end, if it exists. If there are any components other than year, month, /// - endDate: An exclusive end date. If omitted, the calendar event ends on the same date as start. - /// - location: The location of the calendar event. e.g. address, GPS coordinates, meeting room name, link to video call. + /// - locations: The locations of the calendar event. e.g. address, GPS coordinates, meeting room name, link to video call. /// - geohash: The [geohash](https://en.wikipedia.org/wiki/Geohash) to associate calendar event with a searchable physical location. /// - participants: The participants of the calendar event. /// - hashtags: Hashtags to categorize the calendar event. @@ -353,7 +354,7 @@ public extension EventCreating { /// - Returns: The signed ``DateBasedCalendarEvent``. /// /// See [NIP-52](https://github.com/nostr-protocol/nips/blob/master/52.md). - func dateBasedCalendarEvent(withName name: String, description: String = "", startDate: TimeOmittedDate, endDate: TimeOmittedDate? = nil, location: String? = nil, geohash: String? = nil, participants: [CalendarEventParticipant]? = nil, hashtags: [String]? = nil, references: [URL]? = nil, signedBy keypair: Keypair) throws -> DateBasedCalendarEvent { + func dateBasedCalendarEvent(withIdentifier identifier: String = UUID().uuidString, title: String, description: String = "", startDate: TimeOmittedDate, endDate: TimeOmittedDate? = nil, locations: [String]? = nil, geohash: String? = nil, participants: [CalendarEventParticipant]? = nil, hashtags: [String]? = nil, references: [URL]? = nil, signedBy keypair: Keypair) throws -> DateBasedCalendarEvent { var tags: [Tag] = [] @@ -370,13 +371,13 @@ public extension EventCreating { // Re-arrange tags so that it's easier to read with the identifier and name appearing first in the list of tags, // and the end date being placed next to the start date. tags = [ - Tag(name: .identifier, value: UUID().uuidString), - Tag(name: "name", value: name), + Tag(name: .identifier, value: identifier), + Tag(name: .title, value: title), Tag(name: "start", value: startDate.dateString) ] + tags - if let location { - tags.append(Tag(name: "location", value: location)) + if let locations, !locations.isEmpty { + tags += locations.map { Tag(name: "location", value: $0) } } if let geohash { @@ -400,13 +401,14 @@ public extension EventCreating { /// Creates a ``TimeBasedCalendarEvent`` (kind 31923) which spans between a start time and end time. /// - Parameters: - /// - name: The name of the calendar event. + /// - identifier: A unique identifier for the calendar event. Can be reused in the future for replacing the calendar event. If an identifier is not provided, a ``UUID`` string is used. + /// - title: The title of the calendar event. /// - description: A detailed description of the calendar event. /// - startTimestamp: An inclusive start timestamp. /// - endTimestamp: An exclusive end timestamp. If omitted, the calendar event ends instantaneously. /// - startTimeZone: The time zone of the start timestamp. /// - endTimeZone: The time zone of the end timestamp. If omitted and startTimeZone is provided, the time zone of the end timestamp is the same as the start timestamp. - /// - location: The location of the calendar event. e.g. address, GPS coordinates, meeting room name, link to video call. + /// - locations: The locations of the calendar event. e.g. address, GPS coordinates, meeting room name, link to video call. /// - geohash: The [geohash](https://en.wikipedia.org/wiki/Geohash) to associate calendar event with a searchable physical location. /// - participants: The participants of the calendar event. /// - hashtags: Hashtags to categorize the calendar event. @@ -415,7 +417,7 @@ public extension EventCreating { /// - Returns: The signed ``TimeBasedCalendarEvent``. /// /// See [NIP-52](https://github.com/nostr-protocol/nips/blob/master/52.md). - func timeBasedCalendarEvent(withName name: String, description: String = "", startTimestamp: Date, endTimestamp: Date? = nil, startTimeZone: TimeZone? = nil, endTimeZone: TimeZone? = nil, location: String? = nil, geohash: String? = nil, participants: [CalendarEventParticipant]? = nil, hashtags: [String]? = nil, references: [URL]? = nil, signedBy keypair: Keypair) throws -> TimeBasedCalendarEvent { + func timeBasedCalendarEvent(withIdentifier identifier: String = UUID().uuidString, title: String, description: String = "", startTimestamp: Date, endTimestamp: Date? = nil, startTimeZone: TimeZone? = nil, endTimeZone: TimeZone? = nil, locations: [String]? = nil, geohash: String? = nil, participants: [CalendarEventParticipant]? = nil, hashtags: [String]? = nil, references: [URL]? = nil, signedBy keypair: Keypair) throws -> TimeBasedCalendarEvent { // If the end timestamp is omitted, the calendar event ends instantaneously. if let endTimestamp { @@ -426,8 +428,8 @@ public extension EventCreating { } var tags: [Tag] = [ - Tag(name: .identifier, value: UUID().uuidString), - Tag(name: "name", value: name), + Tag(name: .identifier, value: identifier), + Tag(name: .title, value: title), Tag(name: "start", value: String(Int64(startTimestamp.timeIntervalSince1970))) ] @@ -444,8 +446,8 @@ public extension EventCreating { tags.append(Tag(name: "end_tzid", value: endTimeZone.identifier)) } - if let location { - tags.append(Tag(name: "location", value: location)) + if let locations, !locations.isEmpty { + tags += locations.map { Tag(name: "location", value: $0) } } if let geohash { @@ -466,4 +468,64 @@ public extension EventCreating { return try TimeBasedCalendarEvent(content: description, tags: tags, signedBy: keypair) } + + /// Creates a ``CalendarNostrEvent`` (kind 31924), which is a collection of date-based and time-based calendar events. + /// - Parameters: + /// - identifier: A unique identifier for the calendar. Can be reused in the future for replacing the calendar. If an identifier is not provided, a ``UUID`` string is used. + /// - title: The title of the calendar. + /// - description: A detailed description of the calendar. + /// - calendarEventsCoordinates: The coordinates to date-based or time-based calendar events that belong to this calendar. + /// - keypair: The Keypair to sign with. + /// - Returns: The signed ``CalendarNostrEvent``. + /// + /// See [NIP-52](https://github.com/nostr-protocol/nips/blob/master/52.md). + func calendarNostrEvent(withIdentifier identifier: String = UUID().uuidString, title: String, description: String = "", calendarEventsCoordinates: [EventCoordinates], signedBy keypair: Keypair) throws -> CalendarNostrEvent { + guard calendarEventsCoordinates.allSatisfy({ $0.kind == .dateBasedCalendarEvent || $0.kind == .timeBasedCalendarEvent }) else { + throw EventCreatingError.invalidInput + } + + var tags: [Tag] = [ + Tag(name: .identifier, value: identifier), + Tag(name: .title, value: title) + ] + + calendarEventsCoordinates + .filter { $0.kind == .dateBasedCalendarEvent || $0.kind == .timeBasedCalendarEvent } + .forEach { tags.append($0.tag) } + + return try CalendarNostrEvent(content: description, tags: tags, signedBy: keypair) + } + + /// Creates a ``CalendarEventRSVP`` (kind 31925), which is a response to a calendar event to indicate a user's attendance intention. + /// - Parameters: + /// - identifier: A unique identifier for the calendar event RSVP. Can be reused in the future for replacing the calendar event RSVP. If an identifier is not provided, a ``UUID`` string is used. + /// - calendarEventCoordinates: The coordinates to date-based or time-based calendar event being responded to. + /// - status: The attendance status to the referenced calendar event. + /// - freebusy: Determines if the user would be free or busy for the duration of the calendar event. This tag must be omitted or ignored if the status label is set to declined. + /// - note: A free-form note that adds more context to this calendar event response. + /// - keypair: The Keypair to sign with. + /// - Returns: The signed ``CalendarEventRSVP``. + /// + /// See [NIP-52](https://github.com/nostr-protocol/nips/blob/master/52.md). + func calendarEventRSVP(withIdentifier identifier: String = UUID().uuidString, calendarEventCoordinates: EventCoordinates, status: CalendarEventRSVPStatus, freebusy: CalendarEventRSVPFreebusy? = nil, note: String = "", signedBy keypair: Keypair) throws -> CalendarEventRSVP { + guard calendarEventCoordinates.kind == .dateBasedCalendarEvent || calendarEventCoordinates.kind == .timeBasedCalendarEvent, + // Status must not be unknown, and freebusy must be omitted if status is declined. + status == .accepted || status == .tentative || (status == .declined && freebusy == nil) else { + throw EventCreatingError.invalidInput + } + + var tags: [Tag] = [ + calendarEventCoordinates.tag, + Tag(name: .identifier, value: identifier), + Tag(name: .labelNamespace, value: "status"), + Tag(name: .label, value: status.rawValue, otherParameters: ["status"]) + ] + + if let freebusy { + tags.append(Tag(name: .labelNamespace, value: "freebusy")) + tags.append(Tag(name: .label, value: freebusy.rawValue, otherParameters: ["freebusy"])) + } + + return try CalendarEventRSVP(content: note, tags: tags, signedBy: keypair) + } } diff --git a/Sources/NostrSDK/EventKind.swift b/Sources/NostrSDK/EventKind.swift index 62fec4b..b662fa6 100644 --- a/Sources/NostrSDK/EventKind.swift +++ b/Sources/NostrSDK/EventKind.swift @@ -79,13 +79,23 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable { case longformContent /// This kind of event represents an occurrence that spans between a start date and end date. - /// See [NIP-52 - Date-Based Calendar Event](https://github.com/nostr-protocol/nips/blob/master/52.md#calendar-events-1) + /// See [NIP-52 - Date-Based Calendar Event](https://github.com/nostr-protocol/nips/blob/master/52.md#calendar-events-1). case dateBasedCalendarEvent /// This kind of event represents an occurrence between moments in time. - /// See [NIP-52 - Time-Based Calendar Event](https://github.com/nostr-protocol/nips/blob/master/52.md#time-based-calendar-event) + /// See [NIP-52 - Time-Based Calendar Event](https://github.com/nostr-protocol/nips/blob/master/52.md#time-based-calendar-event). case timeBasedCalendarEvent + /// This kind of event represents a calendar, which is a collection of calendar events. + /// It is represented as a custom replaceable list event. A user can have multiple calendars. + /// One may create a calendar to segment calendar events for specific purposes. e.g., personal, work, travel, meetups, and conferences. + /// See [NIP-52 - Calendar](https://github.com/nostr-protocol/nips/blob/master/52.md#calendar). + case calendar + + /// This kind of event represents a calendar event RSVP, which is a response to a calendar event to indicate a user's attendance intention. + /// See [NIP-52 - Calendar Event RSVP](https://github.com/nostr-protocol/nips/blob/master/52.md#calendar-event-rsvp). + case calendarEventRSVP + /// Any other event kind number that isn't supported by this enum yet will be represented by `unknown` so that `NostrEvent`s of those event kinds can still be encoded and decoded. case unknown(RawValue) @@ -104,7 +114,9 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable { .muteList, .longformContent, .dateBasedCalendarEvent, - .timeBasedCalendarEvent + .timeBasedCalendarEvent, + .calendar, + .calendarEventRSVP ] public init(rawValue: Int) { @@ -128,6 +140,8 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable { case .longformContent: return 30023 case .dateBasedCalendarEvent: return 31922 case .timeBasedCalendarEvent: return 31923 + case .calendar: return 31924 + case .calendarEventRSVP: return 31925 case let .unknown(value): return value } } diff --git a/Sources/NostrSDK/Events/Calendars/CalendarEventInterpreting.swift b/Sources/NostrSDK/Events/Calendars/CalendarEventInterpreting.swift index 422293d..3873480 100644 --- a/Sources/NostrSDK/Events/Calendars/CalendarEventInterpreting.swift +++ b/Sources/NostrSDK/Events/Calendars/CalendarEventInterpreting.swift @@ -7,16 +7,17 @@ import Foundation -public protocol CalendarEventInterpreting: NostrEvent, CalendarEventParticipantInterpreting, HashtagInterpreting, IdentifierTagInterpreting, ReferenceTagInterpreting {} +public protocol CalendarEventInterpreting: NostrEvent, CalendarEventParticipantInterpreting, HashtagInterpreting, IdentifierTagInterpreting, ReferenceTagInterpreting, TitleTagInterpreting {} public extension CalendarEventInterpreting { /// The name of the calendar event. + @available(*, deprecated, message: "This method of naming a calendar event is out of spec, not preferred, and will be removed in the future. Please use only the title field when it is available.") var name: String? { tags.first { $0.name == "name" }?.value } - /// The location of the calendar event. e.g. address, GPS coordinates, meeting room name, link to video call. - var location: String? { - tags.first { $0.name == "location" }?.value + /// The locations of the calendar event. e.g. address, GPS coordinates, meeting room name, link to video call. + var locations: [String] { + tags.filter { $0.name == "location" }.map { $0.value } } /// The [geohash](https://en.wikipedia.org/wiki/Geohash) to associate calendar event with a searchable physical location. diff --git a/Sources/NostrSDK/Events/Calendars/CalendarEventRSVP.swift b/Sources/NostrSDK/Events/Calendars/CalendarEventRSVP.swift new file mode 100644 index 0000000..3c0ee27 --- /dev/null +++ b/Sources/NostrSDK/Events/Calendars/CalendarEventRSVP.swift @@ -0,0 +1,49 @@ +// +// CalendarEventRSVP.swift +// +// +// Created by Terry Yiu on 12/1/23. +// + +import Foundation + +/// A calendar event RSVP is a response to a calendar event to indicate a user's attendance intention. +/// See [NIP-52 - Calendar Event RSVP](https://github.com/nostr-protocol/nips/blob/master/52.md#calendar-event-rsvp). +public final class CalendarEventRSVP: NostrEvent, IdentifierTagInterpreting { + 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) + } + + public init(content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws { + try super.init(kind: .calendarEventRSVP, content: content, tags: tags, createdAt: createdAt, signedBy: keypair) + } + + /// Event coordinates to the calendar event this RSVP responds to. + public var calendarEventCoordinates: EventCoordinates? { + tags.compactMap { EventCoordinates(eventCoordinatesTag: $0) } + .first { $0.kind == .dateBasedCalendarEvent || $0.kind == .timeBasedCalendarEvent } + } + + /// Determines attendance status to the referenced calendar event. + public var status: CalendarEventRSVPStatus? { + guard let statusTag = tags.first(where: { $0.name == "l" && $0.otherParameters.first == "status" }) else { + return nil + } + + return CalendarEventRSVPStatus(rawValue: statusTag.value) + } + + /// Determines if the user would be free or busy for the duration of the calendar event. + public var freebusy: CalendarEventRSVPFreebusy? { + guard let freebusyTag = tags.first(where: { $0.name == "l" && $0.otherParameters.first == "freebusy" }) else { + return nil + } + + return CalendarEventRSVPFreebusy(rawValue: freebusyTag.value) + } +} diff --git a/Sources/NostrSDK/Events/Calendars/CalendarEventRSVPFreebusy.swift b/Sources/NostrSDK/Events/Calendars/CalendarEventRSVPFreebusy.swift new file mode 100644 index 0000000..b366e19 --- /dev/null +++ b/Sources/NostrSDK/Events/Calendars/CalendarEventRSVPFreebusy.swift @@ -0,0 +1,41 @@ +// +// CalendarEventRSVPFreebusy.swift +// +// +// Created by Terry Yiu on 12/17/23. +// + +import Foundation + +/// Determines if the user would be free or busy for the duration of the calendar event. +public enum CalendarEventRSVPFreebusy: RawRepresentable, CaseIterable, Codable, Equatable { + + public typealias RawValue = String + + /// The user is free for the duration of the calendar event. + case free + + /// The user is busy for the duration of the calendar event. + case busy + + /// Unknown freebusy state. + case unknown(RawValue) + + static public let allCases: AllCases = [ + .free, + .busy + ] + + public init(rawValue: String) { + self = Self.allCases.first { $0.rawValue == rawValue } + ?? .unknown(rawValue) + } + + public var rawValue: RawValue { + switch self { + case .free: return "free" + case .busy: return "busy" + case let .unknown(value): return value + } + } +} diff --git a/Sources/NostrSDK/Events/Calendars/CalendarEventRSVPStatus.swift b/Sources/NostrSDK/Events/Calendars/CalendarEventRSVPStatus.swift new file mode 100644 index 0000000..e25b146 --- /dev/null +++ b/Sources/NostrSDK/Events/Calendars/CalendarEventRSVPStatus.swift @@ -0,0 +1,46 @@ +// +// CalendarEventRSVPStatus.swift +// +// +// Created by Terry Yiu on 12/17/23. +// + +import Foundation + +/// A calendar event RSVP is a response to a calendar event to indicate a user's attendance intention. +public enum CalendarEventRSVPStatus: RawRepresentable, CaseIterable, Codable, Equatable { + + public typealias RawValue = String + + /// The user has accepted to attend the calendar event. + case accepted + + /// The user has declined to attend the calendar event. + case declined + + /// The user has tentatively accepted to attend the calendar event. + case tentative + + /// Unknown RSVP status. + case unknown(RawValue) + + static public let allCases: AllCases = [ + .accepted, + .declined, + .tentative + ] + + public init(rawValue: String) { + self = Self.allCases.first { $0.rawValue == rawValue } + ?? .unknown(rawValue) + } + + public var rawValue: RawValue { + switch self { + case .accepted: return "accepted" + case .declined: return "declined" + case .tentative: return "tentative" + case let .unknown(value): return value + } + } +} diff --git a/Sources/NostrSDK/Events/Calendars/CalendarNostrEvent.swift b/Sources/NostrSDK/Events/Calendars/CalendarNostrEvent.swift new file mode 100644 index 0000000..86e41e0 --- /dev/null +++ b/Sources/NostrSDK/Events/Calendars/CalendarNostrEvent.swift @@ -0,0 +1,29 @@ +// +// CalendarNostrEvent.swift +// +// +// Created by Terry Yiu on 12/1/23. +// + +import Foundation + +public final class CalendarNostrEvent: NostrEvent, IdentifierTagInterpreting, TitleTagInterpreting { + 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) + } + + public init(content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws { + try super.init(kind: .calendar, content: content, tags: tags, createdAt: createdAt, signedBy: keypair) + } + + /// The event coordinates of the calendar events that belong to this calendar. + public var calendarEventsCoordinates: [EventCoordinates] { + tags.compactMap { EventCoordinates(eventCoordinatesTag: $0) } + .filter { $0.kind == .dateBasedCalendarEvent || $0.kind == .timeBasedCalendarEvent } + } +} diff --git a/Sources/NostrSDK/Events/Tags/ReplaceableEventCoordinates.swift b/Sources/NostrSDK/Events/Tags/EventCoordinates.swift similarity index 90% rename from Sources/NostrSDK/Events/Tags/ReplaceableEventCoordinates.swift rename to Sources/NostrSDK/Events/Tags/EventCoordinates.swift index f448080..b790f1f 100644 --- a/Sources/NostrSDK/Events/Tags/ReplaceableEventCoordinates.swift +++ b/Sources/NostrSDK/Events/Tags/EventCoordinates.swift @@ -1,5 +1,5 @@ // -// ReplaceableEventCoordinates.swift +// EventCoordinates.swift // // // Created by Terry Yiu on 12/16/23. @@ -9,7 +9,7 @@ import Foundation /// Coordinates to a (maybe parameterized) replaceable event. /// See [NIP-01 Tags](https://github.com/nostr-protocol/nips/blob/master/01.md#tags). -public struct ReplaceableEventCoordinates: PubkeyProviding, RelayProviding, RelayURLValidating, Equatable { +public struct EventCoordinates: PubkeyProviding, RelayProviding, RelayURLValidating, Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { lhs.tag == rhs.tag } @@ -113,11 +113,11 @@ public struct ReplaceableEventCoordinates: PubkeyProviding, RelayProviding, Rela } } -public protocol ReplaceableEventCoordinatesInterpreting: NostrEvent {} -public extension ReplaceableEventCoordinatesInterpreting { +public protocol EventCoordinatesInterpreting: NostrEvent {} +public extension EventCoordinatesInterpreting { /// The referenced replaceable event tags of the event. - var eventCoordinates: [ReplaceableEventCoordinates] { + var eventCoordinates: [EventCoordinates] { tags.filter { $0.name == TagName.eventCoordinates.rawValue } - .compactMap { ReplaceableEventCoordinates(eventCoordinatesTag: $0) } + .compactMap { EventCoordinates(eventCoordinatesTag: $0) } } } diff --git a/Sources/NostrSDK/Events/Tags/IdentifierTagInterpreting.swift b/Sources/NostrSDK/Events/Tags/IdentifierTagInterpreting.swift index 0eff3d0..0a09cf8 100644 --- a/Sources/NostrSDK/Events/Tags/IdentifierTagInterpreting.swift +++ b/Sources/NostrSDK/Events/Tags/IdentifierTagInterpreting.swift @@ -14,4 +14,16 @@ public extension IdentifierTagInterpreting { var identifier: String? { firstValueForTagName(.identifier) } + + /// The event coordinates that can be used to fetch this replaceable event's kind, pubkey, and identifier from a relay. + /// + /// - Parameters: + /// - relayURL: A relay URL that this replaceable event could be found. + func identifierEventCoordinates(_ relayURL: URL? = nil) -> EventCoordinates? { + guard let identifier, let publicKey = PublicKey(hex: pubkey) else { + return nil + } + + return EventCoordinates(kind: kind, pubkey: publicKey, identifier: identifier, relayURL: relayURL) + } } diff --git a/Sources/NostrSDK/Tag.swift b/Sources/NostrSDK/Tag.swift index 50eb4d9..45c6af7 100644 --- a/Sources/NostrSDK/Tag.swift +++ b/Sources/NostrSDK/Tag.swift @@ -33,7 +33,13 @@ public enum TagName: String { /// a stringified kind number case kind = "k" - + + /// labels other entities + case label = "l" + + /// namespace for a label + case labelNamespace = "L" + /// a short subject for a text note, similar to subjects in emails case subject diff --git a/Tests/NostrSDKTests/ReplaceableEventCoordinatesTests.swift b/Tests/NostrSDKTests/EventCoordinatesTests.swift similarity index 50% rename from Tests/NostrSDKTests/ReplaceableEventCoordinatesTests.swift rename to Tests/NostrSDKTests/EventCoordinatesTests.swift index e9361a5..67ef47f 100644 --- a/Tests/NostrSDKTests/ReplaceableEventCoordinatesTests.swift +++ b/Tests/NostrSDKTests/EventCoordinatesTests.swift @@ -1,5 +1,5 @@ // -// ReplaceableEventCoordinatesTests.swift +// EventCoordinatesTests.swift // // // Created by Terry Yiu on 12/16/23. @@ -8,7 +8,7 @@ import XCTest @testable import NostrSDK -final class ReplaceableEventCoordinatesTests: XCTestCase { +final class EventCoordinatesTests: XCTestCase { func testInit() throws { let kind: EventKind = .longformContent @@ -16,8 +16,8 @@ final class ReplaceableEventCoordinatesTests: XCTestCase { let identifier = "F8SII-G5LDumDZgxGCVQS" let relay = "wss://relay.nostrsdk.com" - let replaceableEventCoordinates = try XCTUnwrap( - ReplaceableEventCoordinates( + let eventCoordinates = try XCTUnwrap( + EventCoordinates( kind: kind, pubkey: pubkey, identifier: identifier, @@ -25,44 +25,44 @@ final class ReplaceableEventCoordinatesTests: XCTestCase { ) ) - XCTAssertEqual(replaceableEventCoordinates.kind, kind) - XCTAssertEqual(replaceableEventCoordinates.pubkey, pubkey) - XCTAssertEqual(replaceableEventCoordinates.identifier, identifier) - XCTAssertEqual(replaceableEventCoordinates.relayURL?.absoluteString, relay) + XCTAssertEqual(eventCoordinates.kind, kind) + XCTAssertEqual(eventCoordinates.pubkey, pubkey) + XCTAssertEqual(eventCoordinates.identifier, identifier) + XCTAssertEqual(eventCoordinates.relayURL?.absoluteString, relay) } func testInitFromTag() throws { let tag = Tag(name: .eventCoordinates, value: "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", otherParameters: ["wss://relay.nostr.org"]) - let replaceableEventCoordinates = try XCTUnwrap( - ReplaceableEventCoordinates(eventCoordinatesTag: tag) + let eventCoordinates = try XCTUnwrap( + EventCoordinates(eventCoordinatesTag: tag) ) - XCTAssertEqual(replaceableEventCoordinates.kind?.rawValue, 30023) - XCTAssertEqual(replaceableEventCoordinates.pubkey?.hex, "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919") - XCTAssertEqual(replaceableEventCoordinates.identifier, "ipsum") - XCTAssertEqual(replaceableEventCoordinates.relayURL?.absoluteString, "wss://relay.nostr.org") + XCTAssertEqual(eventCoordinates.kind?.rawValue, 30023) + XCTAssertEqual(eventCoordinates.pubkey?.hex, "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919") + XCTAssertEqual(eventCoordinates.identifier, "ipsum") + XCTAssertEqual(eventCoordinates.relayURL?.absoluteString, "wss://relay.nostr.org") } func testInitFromTagAndInvalidRelayURL() throws { let tag = Tag(name: .eventCoordinates, value: "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", otherParameters: ["https://relay.nostr.org"]) - let replaceableEventCoordinates = try XCTUnwrap( - ReplaceableEventCoordinates(eventCoordinatesTag: tag) + let eventCoordinates = try XCTUnwrap( + EventCoordinates(eventCoordinatesTag: tag) ) - XCTAssertEqual(replaceableEventCoordinates.kind?.rawValue, 30023) - XCTAssertEqual(replaceableEventCoordinates.pubkey?.hex, "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919") - XCTAssertEqual(replaceableEventCoordinates.identifier, "ipsum") + XCTAssertEqual(eventCoordinates.kind?.rawValue, 30023) + XCTAssertEqual(eventCoordinates.pubkey?.hex, "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919") + XCTAssertEqual(eventCoordinates.identifier, "ipsum") // If the coordinates came from a relay but the relay URL is malformed, the rest of the data may still be valid. // Rather than just failing the initialization, we will just return nil if relayURL is called. - XCTAssertNil(replaceableEventCoordinates.relayURL) + XCTAssertNil(eventCoordinates.relayURL) } func testInitFailsOnInvalidRelayURL() throws { XCTAssertNil( - ReplaceableEventCoordinates( + EventCoordinates( kind: .longformContent, pubkey: Keypair.test.publicKey, identifier: "F8SII-G5LDumDZgxGCVQS", diff --git a/Tests/NostrSDKTests/EventCreatingTests.swift b/Tests/NostrSDKTests/EventCreatingTests.swift index bb6609e..f7bd3d1 100644 --- a/Tests/NostrSDKTests/EventCreatingTests.swift +++ b/Tests/NostrSDKTests/EventCreatingTests.swift @@ -407,13 +407,14 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu } func testDateBasedCalendarEvent() throws { - let name = "Nostrica" + let identifier = "nostrica-12345" + let title = "Nostrica" let description = "First Nostr unconference" let startDate = try XCTUnwrap(TimeOmittedDate(year: 2023, month: 3, day: 19)) let endDate = try XCTUnwrap(TimeOmittedDate(year: 2023, month: 3, day: 21)) - let location = "Awake, C. Garcias, Provincia de Puntarenas, Uvita, 60504, Costa Rica" + let locations = ["Awake, C. Garcias, Provincia de Puntarenas, Uvita, 60504, Costa Rica", "YouTube"] let geohash = "d1sknt77t3xn" let relayURL = try XCTUnwrap(URL(string: "wss://relay.nostrsdk.com")) @@ -427,11 +428,12 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu let references = [reference1, reference2] let dateBasedCalendarEvent = try dateBasedCalendarEvent( - withName: name, + withIdentifier: identifier, + title: title, description: description, startDate: startDate, endDate: endDate, - location: location, + locations: locations, geohash: geohash, participants: participants, hashtags: hashtags, @@ -439,11 +441,12 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu signedBy: Keypair.test ) - XCTAssertEqual(dateBasedCalendarEvent.name, name) + XCTAssertEqual(dateBasedCalendarEvent.identifier, identifier) + XCTAssertEqual(dateBasedCalendarEvent.title, title) XCTAssertEqual(dateBasedCalendarEvent.content, description) XCTAssertEqual(dateBasedCalendarEvent.startDate, startDate) XCTAssertEqual(dateBasedCalendarEvent.endDate, endDate) - XCTAssertEqual(dateBasedCalendarEvent.location, location) + XCTAssertEqual(dateBasedCalendarEvent.locations, locations) XCTAssertEqual(dateBasedCalendarEvent.geohash, geohash) XCTAssertEqual(dateBasedCalendarEvent.participants, participants) XCTAssertEqual(dateBasedCalendarEvent.hashtags, hashtags) @@ -453,24 +456,25 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu } func testDateBasedCalendarEventWithStartDateSameAsEndDateShouldFail() throws { - let name = "Nostrica" + let title = "Nostrica" let description = "First Nostr unconference" let timeOmittedDate = try XCTUnwrap(TimeOmittedDate(year: 2023, month: 3, day: 19)) - XCTAssertThrowsError(try dateBasedCalendarEvent(withName: name, description: description, startDate: timeOmittedDate, endDate: timeOmittedDate, signedBy: Keypair.test)) + XCTAssertThrowsError(try dateBasedCalendarEvent(title: title, description: description, startDate: timeOmittedDate, endDate: timeOmittedDate, signedBy: Keypair.test)) } func testDateBasedCalendarEventWithEndDateBeforeStartDateShouldFail() throws { - let name = "Nostrica" + let title = "Nostrica" let description = "First Nostr unconference" let startDate = try XCTUnwrap(TimeOmittedDate(year: 2023, month: 3, day: 19)) let endDate = try XCTUnwrap(TimeOmittedDate(year: 2023, month: 3, day: 18)) - XCTAssertThrowsError(try dateBasedCalendarEvent(withName: name, description: description, startDate: startDate, endDate: endDate, signedBy: Keypair.test)) + XCTAssertThrowsError(try dateBasedCalendarEvent(title: title, description: description, startDate: startDate, endDate: endDate, signedBy: Keypair.test)) } func testTimeBasedCalendarEvent() throws { - let name = "Flight from New York (JFK) to San José, Costa Rica (SJO)" + let identifier = "flight-from-new-york-jfk-to-san-jose-costa-rica-sjo-12345" + let title = "Flight from New York (JFK) to San José, Costa Rica (SJO)" let description = "Flight to Nostrica" let startTimeZone = TimeZone(identifier: "America/New_York") @@ -496,13 +500,14 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu let references = [reference1, reference2] let timeBasedCalendarEvent = try timeBasedCalendarEvent( - withName: name, + withIdentifier: identifier, + title: title, description: description, startTimestamp: startTimestamp, endTimestamp: endTimestamp, startTimeZone: startTimeZone, endTimeZone: endTimeZone, - location: location, + locations: [location], geohash: geohash, participants: participants, hashtags: hashtags, @@ -510,13 +515,14 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu signedBy: Keypair.test ) - XCTAssertEqual(timeBasedCalendarEvent.name, name) + XCTAssertEqual(timeBasedCalendarEvent.identifier, identifier) + XCTAssertEqual(timeBasedCalendarEvent.title, title) XCTAssertEqual(timeBasedCalendarEvent.content, description) XCTAssertEqual(timeBasedCalendarEvent.startTimestamp, startTimestamp) XCTAssertEqual(timeBasedCalendarEvent.endTimestamp, endTimestamp) XCTAssertEqual(timeBasedCalendarEvent.startTimeZone, startTimeZone) XCTAssertEqual(timeBasedCalendarEvent.endTimeZone, endTimeZone) - XCTAssertEqual(timeBasedCalendarEvent.location, location) + XCTAssertEqual(timeBasedCalendarEvent.locations, [location]) XCTAssertEqual(timeBasedCalendarEvent.geohash, geohash) XCTAssertEqual(timeBasedCalendarEvent.participants, participants) XCTAssertEqual(timeBasedCalendarEvent.hashtags, hashtags) @@ -526,18 +532,18 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu } func testTimeBasedCalendarEventWithStartTimestampSameAsEndTimestampShouldFail() throws { - let name = "Flight from New York (JFK) to San José, Costa Rica (SJO)" + let title = "Flight from New York (JFK) to San José, Costa Rica (SJO)" let description = "Flight to Nostrica" let timeZone = TimeZone(identifier: "America/New_York") let dateComponents = DateComponents(calendar: Calendar(identifier: .iso8601), timeZone: timeZone, year: 2023, month: 3, day: 17, hour: 8, minute: 15) let timestamp = try XCTUnwrap(dateComponents.date) - XCTAssertThrowsError(try timeBasedCalendarEvent(withName: name, description: description, startTimestamp: timestamp, endTimestamp: timestamp, signedBy: Keypair.test)) + XCTAssertThrowsError(try timeBasedCalendarEvent(title: title, description: description, startTimestamp: timestamp, endTimestamp: timestamp, signedBy: Keypair.test)) } func testTimeBasedCalendarEventWithEndTimestampBeforeStartTimestampShouldFail() throws { - let name = "Flight from New York (JFK) to San José, Costa Rica (SJO)" + let title = "Flight from New York (JFK) to San José, Costa Rica (SJO)" let description = "Flight to Nostrica" let timeZone = TimeZone(identifier: "America/New_York") @@ -547,6 +553,132 @@ final class EventCreatingTests: XCTestCase, EventCreating, EventVerifying, Fixtu let startTimestamp = try XCTUnwrap(startComponents.date) let endTimestamp = try XCTUnwrap(endComponents.date) - XCTAssertThrowsError(try timeBasedCalendarEvent(withName: name, description: description, startTimestamp: startTimestamp, endTimestamp: endTimestamp, signedBy: Keypair.test)) + XCTAssertThrowsError(try timeBasedCalendarEvent(title: title, description: description, startTimestamp: startTimestamp, endTimestamp: endTimestamp, signedBy: Keypair.test)) + } + + func testCalendar() throws { + let timeOmittedStartDate = try XCTUnwrap(TimeOmittedDate(year: 2023, month: 12, day: 31)) + let dateBasedCalendarEvent = try XCTUnwrap(dateBasedCalendarEvent(title: "New Year's Eve", startDate: timeOmittedStartDate, signedBy: Keypair.test)) + let dateBasedCalendarEventCoordinates = try XCTUnwrap(dateBasedCalendarEvent.identifierEventCoordinates()) + + let startTimeZone = TimeZone(identifier: "America/New_York") + let startComponents = DateComponents(calendar: Calendar(identifier: .iso8601), timeZone: startTimeZone, year: 2023, month: 12, day: 20, hour: 8, minute: 0) + let startDate = try XCTUnwrap(startComponents.date) + let timeBasedCalendarEvent = try timeBasedCalendarEvent(title: "Hockey Practice", startTimestamp: startDate, signedBy: Keypair.test) + let timeBasedCalendarEventCoordinates = try XCTUnwrap(timeBasedCalendarEvent.identifierEventCoordinates()) + + let identifier = "family-calendar" + let title = "Family Calendar" + let description = "All family events." + let calendar = try calendarNostrEvent(withIdentifier: identifier, title: title, description: description, calendarEventsCoordinates: [dateBasedCalendarEventCoordinates, timeBasedCalendarEventCoordinates], signedBy: Keypair.test) + + XCTAssertEqual(calendar.identifier, identifier) + XCTAssertEqual(calendar.title, title) + XCTAssertEqual(calendar.content, description) + XCTAssertEqual(calendar.calendarEventsCoordinates, [dateBasedCalendarEventCoordinates, timeBasedCalendarEventCoordinates]) + + try verifyEvent(calendar) + + print(calendar.signature) + print(calendar.calculatedId) + print(calendar.serialized) + } + + func testCalendarWithNoCalendarEventCoordinates() throws { + let identifier = "family-calendar" + let title = "Family Calendar" + let description = "All family events." + let calendar = try calendarNostrEvent(withIdentifier: identifier, title: title, description: description, calendarEventsCoordinates: [], signedBy: Keypair.test) + + XCTAssertEqual(calendar.identifier, identifier) + XCTAssertEqual(calendar.title, title) + XCTAssertEqual(calendar.content, description) + XCTAssertEqual(calendar.calendarEventsCoordinates, []) + + try verifyEvent(calendar) + } + + func testCalendarWithInvalidCalendarEventCoordinatesShouldFail() throws { + let identifier = "family-calendar" + let title = "Family Calendar" + let description = "All family events." + let eventCoordinates = try XCTUnwrap(EventCoordinates(kind: EventKind.textNote, pubkey: Keypair.test.publicKey, identifier: "abc")) + + XCTAssertThrowsError(try calendarNostrEvent(withIdentifier: identifier, title: title, description: description, calendarEventsCoordinates: [eventCoordinates], signedBy: Keypair.test)) + } + + func testDateBasedCalendarEventRSVP() throws { + let timeOmittedStartDate = try XCTUnwrap(TimeOmittedDate(year: 2023, month: 12, day: 31)) + let dateBasedCalendarEvent = try XCTUnwrap(dateBasedCalendarEvent(title: "New Year's Eve", startDate: timeOmittedStartDate, signedBy: Keypair.test)) + let dateBasedCalendarEventCoordinates = try XCTUnwrap(dateBasedCalendarEvent.identifierEventCoordinates()) + + let identifier = "hockey-practice-rsvp" + let note = "Don't forget your skates!" + let calendarEventRSVP = try calendarEventRSVP(withIdentifier: identifier, calendarEventCoordinates: dateBasedCalendarEventCoordinates, status: .accepted, freebusy: .busy, note: note, signedBy: Keypair.test) + + XCTAssertEqual(calendarEventRSVP.identifier, identifier) + XCTAssertEqual(calendarEventRSVP.calendarEventCoordinates, dateBasedCalendarEventCoordinates) + XCTAssertEqual(calendarEventRSVP.status, .accepted) + XCTAssertEqual(calendarEventRSVP.freebusy, .busy) + XCTAssertEqual(calendarEventRSVP.content, note) + + try verifyEvent(calendarEventRSVP) + } + + func testTimeBasedCalendarEventRSVP() throws { + let startTimeZone = TimeZone(identifier: "America/New_York") + let startComponents = DateComponents(calendar: Calendar(identifier: .iso8601), timeZone: startTimeZone, year: 2023, month: 12, day: 20, hour: 8, minute: 0) + let startDate = try XCTUnwrap(startComponents.date) + let timeBasedCalendarEvent = try timeBasedCalendarEvent(title: "Hockey Practice", startTimestamp: startDate, signedBy: Keypair.test) + let timeBasedCalendarEventCoordinates = try XCTUnwrap(timeBasedCalendarEvent.identifierEventCoordinates()) + + let identifier = "hockey-practice-rsvp" + let note = "Don't forget your skates!" + let calendarEventRSVP = try calendarEventRSVP(withIdentifier: identifier, calendarEventCoordinates: timeBasedCalendarEventCoordinates, status: .accepted, freebusy: .busy, note: note, signedBy: Keypair.test) + + XCTAssertEqual(calendarEventRSVP.identifier, identifier) + XCTAssertEqual(calendarEventRSVP.calendarEventCoordinates, timeBasedCalendarEventCoordinates) + XCTAssertEqual(calendarEventRSVP.status, .accepted) + XCTAssertEqual(calendarEventRSVP.freebusy, .busy) + XCTAssertEqual(calendarEventRSVP.content, note) + + try verifyEvent(calendarEventRSVP) + } + + func testCalendarEventRSVPWithInvalidCalendarEventCoordinatesShouldFail() throws { + let identifier = "hockey-practice-rsvp" + let note = "Don't forget your skates!" + let eventCoordinates = try XCTUnwrap(EventCoordinates(kind: EventKind.textNote, pubkey: Keypair.test.publicKey, identifier: "abc")) + + XCTAssertThrowsError(try calendarEventRSVP(withIdentifier: identifier, calendarEventCoordinates: eventCoordinates, status: .accepted, freebusy: .busy, note: note, signedBy: Keypair.test)) + } + + func testCalendarEventRSVPWithDeclineAndNoFreebusy() throws { + let startTimeZone = TimeZone(identifier: "America/New_York") + let startComponents = DateComponents(calendar: Calendar(identifier: .iso8601), timeZone: startTimeZone, year: 2023, month: 12, day: 20, hour: 8, minute: 0) + let startDate = try XCTUnwrap(startComponents.date) + let timeBasedCalendarEvent = try timeBasedCalendarEvent(title: "Hockey Practice", startTimestamp: startDate, signedBy: Keypair.test) + let timeBasedCalendarEventCoordinates = try XCTUnwrap(timeBasedCalendarEvent.identifierEventCoordinates()) + + let identifier = "hockey-practice-rsvp" + let calendarEventRSVP = try calendarEventRSVP(withIdentifier: identifier, calendarEventCoordinates: timeBasedCalendarEventCoordinates, status: .declined, signedBy: Keypair.test) + + XCTAssertEqual(calendarEventRSVP.identifier, identifier) + XCTAssertEqual(calendarEventRSVP.calendarEventCoordinates, timeBasedCalendarEventCoordinates) + XCTAssertEqual(calendarEventRSVP.status, .declined) + XCTAssertNil(calendarEventRSVP.freebusy) + + try verifyEvent(calendarEventRSVP) + } + + func testCalendarEventRSVPWithDeclineAndFreebusyShouldFail() throws { + let startTimeZone = TimeZone(identifier: "America/New_York") + let startComponents = DateComponents(calendar: Calendar(identifier: .iso8601), timeZone: startTimeZone, year: 2023, month: 12, day: 20, hour: 8, minute: 0) + let startDate = try XCTUnwrap(startComponents.date) + let timeBasedCalendarEvent = try timeBasedCalendarEvent(title: "Hockey Practice", startTimestamp: startDate, signedBy: Keypair.test) + let timeBasedCalendarEventCoordinates = try XCTUnwrap(timeBasedCalendarEvent.identifierEventCoordinates()) + + let identifier = "hockey-practice-rsvp" + XCTAssertThrowsError(try calendarEventRSVP(withIdentifier: identifier, calendarEventCoordinates: timeBasedCalendarEventCoordinates, status: .declined, freebusy: .busy, signedBy: Keypair.test)) } } diff --git a/Tests/NostrSDKTests/EventDecodingTests.swift b/Tests/NostrSDKTests/EventDecodingTests.swift index 7aed7ec..e2a11bb 100644 --- a/Tests/NostrSDKTests/EventDecodingTests.swift +++ b/Tests/NostrSDKTests/EventDecodingTests.swift @@ -335,10 +335,40 @@ final class EventDecodingTests: XCTestCase, FixtureLoading { let publishedAt = try XCTUnwrap(event.publishedAt?.timeIntervalSince1970) XCTAssertEqual(Int64(publishedAt), 1700532108) } - + func testDecodeDateBasedCalendarEvent() throws { let event: DateBasedCalendarEvent = try decodeFixture(filename: "date_based_calendar_event") + XCTAssertEqual(event.id, "a87228880982599ed0f83411e8ea4f6714f35961f32b2274994897c218ad171d") + XCTAssertEqual(event.pubkey, Keypair.test.publicKey.hex) + XCTAssertEqual(event.createdAt, 1702832309) + XCTAssertEqual(event.kind, .dateBasedCalendarEvent) + XCTAssertEqual(event.identifier, "06E43CF4-D253-4AF9-807A-96FDA4763FF4") + XCTAssertEqual(event.title, "Nostrica") + XCTAssertEqual(event.startDate, TimeOmittedDate(year: 2023, month: 3, day: 19)) + XCTAssertEqual(event.endDate, TimeOmittedDate(year: 2023, month: 3, day: 21)) + XCTAssertEqual(event.locations, ["Awake, C. Garcias, Provincia de Puntarenas, Uvita, 60504, Costa Rica", "YouTube"]) + XCTAssertEqual(event.geohash, "d1sknt77t3xn") + + let participants = event.participants + let expectedParticipantPublicKey = Keypair.test.publicKey + let relayURL = URL(string: "wss://relay.nostrsdk.com") + XCTAssertEqual(participants.count, 2) + XCTAssertEqual(participants, + [CalendarEventParticipant(pubkey: expectedParticipantPublicKey, relayURL: relayURL, role: "organizer"), + CalendarEventParticipant(pubkey: expectedParticipantPublicKey, relayURL: relayURL, role: "attendee")]) + + XCTAssertEqual(event.hashtags, ["nostr", "unconference", "nostrica"]) + + XCTAssertEqual(event.references, [URL(string: "https://nostrica.com/"), URL(string: "https://docs.google.com/document/d/1Gsv09gfuwhqhQerIkxeYQ7iOTjOHUC5oTnL2KKyHpR8/edit")]) + + XCTAssertEqual(event.content, "First Nostr unconference") + XCTAssertEqual(event.signature, "b1f04510811195f69552dc1aff5033f306b4fdf9e6e7c1ac265438b457932266414bdf1ed9ec0c2c2f22d56bef7e519af5c3bfb974c933fd20037918b95dc65a") + } + + func testDecodeDeprecatedDateBasedCalendarEvent() throws { + let event: DateBasedCalendarEvent = try decodeFixture(filename: "date_based_calendar_event_deprecated") + XCTAssertEqual(event.id, "14ff9ea332268384f9f72e2623371dd8edf8dd6b8f8b7f0b3d3df29317148d95") XCTAssertEqual(event.pubkey, Keypair.test.publicKey.hex) XCTAssertEqual(event.createdAt, 1700320160) @@ -347,7 +377,7 @@ final class EventDecodingTests: XCTestCase, FixtureLoading { XCTAssertEqual(event.name, "Nostrica") XCTAssertEqual(event.startDate, TimeOmittedDate(year: 2023, month: 3, day: 19)) XCTAssertEqual(event.endDate, TimeOmittedDate(year: 2023, month: 3, day: 21)) - XCTAssertEqual(event.location, "Awake, C. Garcias, Provincia de Puntarenas, Uvita, 60504, Costa Rica") + XCTAssertEqual(event.locations, ["Awake, C. Garcias, Provincia de Puntarenas, Uvita, 60504, Costa Rica"]) XCTAssertEqual(event.geohash, "d1sknt77t3xn") let participants = event.participants @@ -369,6 +399,38 @@ final class EventDecodingTests: XCTestCase, FixtureLoading { func testDecodeTimeBasedCalendarEvent() throws { let event: TimeBasedCalendarEvent = try decodeFixture(filename: "time_based_calendar_event") + XCTAssertEqual(event.id, "818854c3ff09ac5a2c538cba81d911e59f929dcc5531f61ac92278093d101f1b") + XCTAssertEqual(event.pubkey, Keypair.test.publicKey.hex) + XCTAssertEqual(event.createdAt, 1702833417) + XCTAssertEqual(event.kind, .timeBasedCalendarEvent) + XCTAssertEqual(event.identifier, "798F1F69-1DE3-4623-8DCC-FAF9B773E72B") + XCTAssertEqual(event.title, "Flight from New York (JFK) to San José, Costa Rica (SJO)") + XCTAssertEqual(event.startTimestamp, Date(timeIntervalSince1970: 1679062500)) + XCTAssertEqual(event.endTimestamp, Date(timeIntervalSince1970: 1679067720)) + XCTAssertEqual(event.startTimeZone, TimeZone(identifier: "America/New_York")) + XCTAssertEqual(event.endTimeZone, TimeZone(identifier: "America/Costa_Rica")) + XCTAssertEqual(event.locations, ["John F. Kennedy International Airport, Queens, NY 11430, USA"]) + XCTAssertEqual(event.geohash, "dr5x1p57bg9e") + + let participants = event.participants + let expectedParticipantPublicKey = Keypair.test.publicKey + let relayURL = URL(string: "wss://relay.nostrsdk.com") + XCTAssertEqual(participants.count, 2) + XCTAssertEqual(participants, + [CalendarEventParticipant(pubkey: expectedParticipantPublicKey, relayURL: relayURL, role: "organizer"), + CalendarEventParticipant(pubkey: expectedParticipantPublicKey, relayURL: relayURL, role: "attendee")]) + + XCTAssertEqual(event.hashtags, ["flights", "costarica"]) + + XCTAssertEqual(event.references, [URL(string: "https://nostrica.com/"), URL(string: "https://docs.google.com/document/d/1Gsv09gfuwhqhQerIkxeYQ7iOTjOHUC5oTnL2KKyHpR8/edit")]) + + XCTAssertEqual(event.content, "Flight to Nostrica") + XCTAssertEqual(event.signature, "c2aa36b07c4df050d637dd2be770767c67621e7d87179f9f1e5ef118543328ed238afbd6b33317a61178205b75e6ecb0a61ea4cf6c657a7da0e4cea4842d4c01") + } + + func testDecodeDeprecatedTimeBasedCalendarEvent() throws { + let event: TimeBasedCalendarEvent = try decodeFixture(filename: "time_based_calendar_event_deprecated") + XCTAssertEqual(event.id, "091455f5c9509655e3a4f68f98e807349ac0b5525506b22978566a0bb0f3ced1") XCTAssertEqual(event.pubkey, Keypair.test.publicKey.hex) XCTAssertEqual(event.createdAt, 1700320270) @@ -379,7 +441,7 @@ final class EventDecodingTests: XCTestCase, FixtureLoading { XCTAssertEqual(event.endTimestamp, Date(timeIntervalSince1970: 1679067720)) XCTAssertEqual(event.startTimeZone, TimeZone(identifier: "America/New_York")) XCTAssertEqual(event.endTimeZone, TimeZone(identifier: "America/Costa_Rica")) - XCTAssertEqual(event.location, "John F. Kennedy International Airport, Queens, NY 11430, USA") + XCTAssertEqual(event.locations, ["John F. Kennedy International Airport, Queens, NY 11430, USA"]) XCTAssertEqual(event.geohash, "dr5x1p57bg9e") let participants = event.participants @@ -397,7 +459,46 @@ final class EventDecodingTests: XCTestCase, FixtureLoading { XCTAssertEqual(event.content, "Flight to Nostrica") XCTAssertEqual(event.signature, "57cdb0735645a7ff7112c2d863c425a75e82b540412117609c1c32bee833622a82acacd836b2790f0f08082e2700cdf1ac363c2aae1db87e613824fea2907845") } - + + func testDecodeCalendar() throws { + let event: CalendarNostrEvent = try decodeFixture(filename: "calendar") + + XCTAssertEqual(event.id, "1dc8b913d9d4f50a71182dc9232996d6fbc69e8c955866e43ef2c2e35185bbfa") + XCTAssertEqual(event.pubkey, "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340") + XCTAssertEqual(event.kind, .calendar) + XCTAssertEqual(event.signature, "24c397594fe6d8b5590ce4e7163990f4269bc515d1181487d722730144ac32e8439954d66e88f3232ad807e8d06f01791b5856ae249b139b1469df58045252a9") + XCTAssertEqual(event.createdAt, 1703052671) + XCTAssertEqual(event.identifier, "family-calendar") + XCTAssertEqual(event.title, "Family Calendar") + XCTAssertEqual(event.content, "All family events.") + + let pubkey = try XCTUnwrap(PublicKey(hex: "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340")) + XCTAssertEqual( + event.calendarEventsCoordinates, + [ + EventCoordinates(kind: .dateBasedCalendarEvent, pubkey: pubkey, identifier: "D5EB0A5A-0B36-44DB-95C3-DB51799894E6"), + EventCoordinates(kind: .timeBasedCalendarEvent, pubkey: pubkey, identifier: "1D355ED3-A45D-41A9-B3A5-709211794EFB") + ] + ) + } + + func testDecodeCalendarEventRSVP() throws { + let event: CalendarEventRSVP = try decodeFixture(filename: "calendar_event_rsvp") + + XCTAssertEqual(event.id, "1ec761bbeacd17f4ca961668725ea85a9001a0f56da37eb424856a9de1188a2d") + XCTAssertEqual(event.pubkey, "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340") + XCTAssertEqual(event.kind, .calendarEventRSVP) + XCTAssertEqual(event.signature, "21c58b1d759c6470cbb1931653d3c44cbc24c87be9632b794b2c4bb3a0abd27117dd9e3c8c90cf669a6f0d8204f20f92c2a20ed832a54d999d010402d2b1aa9a") + XCTAssertEqual(event.createdAt, 1703052002) + XCTAssertEqual(event.content, "Don't forget your skates!") + XCTAssertEqual(event.identifier, "hockey-practice-rsvp") + XCTAssertEqual(event.status, .accepted) + XCTAssertEqual(event.freebusy, .busy) + + let pubkey = try XCTUnwrap(PublicKey(hex: "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340")) + XCTAssertEqual(event.calendarEventCoordinates, EventCoordinates(kind: .dateBasedCalendarEvent, pubkey: pubkey, identifier: "D1D48740-2CF8-4483-B5F0-1E83F6D7EC50")) + } + func testDecodeMuteListEvent() throws { let event: MuteListEvent = try decodeFixture(filename: "mute_list") diff --git a/Tests/NostrSDKTests/Fixtures/calendar.json b/Tests/NostrSDKTests/Fixtures/calendar.json new file mode 100644 index 0000000..38e2961 --- /dev/null +++ b/Tests/NostrSDKTests/Fixtures/calendar.json @@ -0,0 +1,26 @@ +{ + "id": "1dc8b913d9d4f50a71182dc9232996d6fbc69e8c955866e43ef2c2e35185bbfa", + "pubkey": "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "created_at": 1703052671, + "kind": 31924, + "tags": [ + [ + "d", + "family-calendar" + ], + [ + "title", + "Family Calendar" + ], + [ + "a", + "31922:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:D5EB0A5A-0B36-44DB-95C3-DB51799894E6" + ], + [ + "a", + "31923:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:1D355ED3-A45D-41A9-B3A5-709211794EFB" + ] + ], + "content": "All family events.", + "sig": "24c397594fe6d8b5590ce4e7163990f4269bc515d1181487d722730144ac32e8439954d66e88f3232ad807e8d06f01791b5856ae249b139b1469df58045252a9" +} diff --git a/Tests/NostrSDKTests/Fixtures/calendar_event_rsvp.json b/Tests/NostrSDKTests/Fixtures/calendar_event_rsvp.json new file mode 100644 index 0000000..d29412e --- /dev/null +++ b/Tests/NostrSDKTests/Fixtures/calendar_event_rsvp.json @@ -0,0 +1,36 @@ +{ + "id": "1ec761bbeacd17f4ca961668725ea85a9001a0f56da37eb424856a9de1188a2d", + "pubkey": "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "created_at": 1703052002, + "kind": 31925, + "tags": [ + [ + "a", + "31922:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:D1D48740-2CF8-4483-B5F0-1E83F6D7EC50" + ], + [ + "d", + "hockey-practice-rsvp" + ], + [ + "L", + "hockey-practice-rsvp" + ], + [ + "l", + "accepted", + "status" + ], + [ + "L", + "freebusy" + ], + [ + "l", + "busy", + "freebusy" + ] + ], + "content": "Don't forget your skates!", + "sig": "21c58b1d759c6470cbb1931653d3c44cbc24c87be9632b794b2c4bb3a0abd27117dd9e3c8c90cf669a6f0d8204f20f92c2a20ed832a54d999d010402d2b1aa9a" +} diff --git a/Tests/NostrSDKTests/Fixtures/date_based_calendar_event.json b/Tests/NostrSDKTests/Fixtures/date_based_calendar_event.json index 05268f9..22e0ddd 100644 --- a/Tests/NostrSDKTests/Fixtures/date_based_calendar_event.json +++ b/Tests/NostrSDKTests/Fixtures/date_based_calendar_event.json @@ -1,15 +1,15 @@ { - "id": "14ff9ea332268384f9f72e2623371dd8edf8dd6b8f8b7f0b3d3df29317148d95", + "id": "a87228880982599ed0f83411e8ea4f6714f35961f32b2274994897c218ad171d", "pubkey": "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", - "created_at": 1700320160, + "created_at": 1702832309, "kind": 31922, "tags": [ [ "d", - "6E28808F-43FD-49FE-8B31-350066FD3886" + "06E43CF4-D253-4AF9-807A-96FDA4763FF4" ], [ - "name", + "title", "Nostrica" ], [ @@ -24,6 +24,10 @@ "location", "Awake, C. Garcias, Provincia de Puntarenas, Uvita, 60504, Costa Rica" ], + [ + "location", + "YouTube" + ], [ "g", "d1sknt77t3xn" @@ -62,5 +66,5 @@ ] ], "content": "First Nostr unconference", - "sig": "5cd7174dff637af03b66f46a6ccc19b526d1fdc987583e6c6ced8fd7b2ce56f37510e989b7037dbf75d84cdac5256275e255e97c6ef42534613f2af78f5925dd" -} \ No newline at end of file + "sig": "b1f04510811195f69552dc1aff5033f306b4fdf9e6e7c1ac265438b457932266414bdf1ed9ec0c2c2f22d56bef7e519af5c3bfb974c933fd20037918b95dc65a" +} diff --git a/Tests/NostrSDKTests/Fixtures/date_based_calendar_event_deprecated.json b/Tests/NostrSDKTests/Fixtures/date_based_calendar_event_deprecated.json new file mode 100644 index 0000000..05268f9 --- /dev/null +++ b/Tests/NostrSDKTests/Fixtures/date_based_calendar_event_deprecated.json @@ -0,0 +1,66 @@ +{ + "id": "14ff9ea332268384f9f72e2623371dd8edf8dd6b8f8b7f0b3d3df29317148d95", + "pubkey": "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "created_at": 1700320160, + "kind": 31922, + "tags": [ + [ + "d", + "6E28808F-43FD-49FE-8B31-350066FD3886" + ], + [ + "name", + "Nostrica" + ], + [ + "start", + "2023-03-19" + ], + [ + "end", + "2023-03-21" + ], + [ + "location", + "Awake, C. Garcias, Provincia de Puntarenas, Uvita, 60504, Costa Rica" + ], + [ + "g", + "d1sknt77t3xn" + ], + [ + "p", + "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "wss://relay.nostrsdk.com", + "organizer" + ], + [ + "p", + "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "wss://relay.nostrsdk.com", + "attendee" + ], + [ + "t", + "nostr" + ], + [ + "t", + "unconference" + ], + [ + "t", + "nostrica" + ], + [ + "r", + "https://nostrica.com/" + ], + [ + "r", + "https://docs.google.com/document/d/1Gsv09gfuwhqhQerIkxeYQ7iOTjOHUC5oTnL2KKyHpR8/edit" + ] + ], + "content": "First Nostr unconference", + "sig": "5cd7174dff637af03b66f46a6ccc19b526d1fdc987583e6c6ced8fd7b2ce56f37510e989b7037dbf75d84cdac5256275e255e97c6ef42534613f2af78f5925dd" +} \ No newline at end of file diff --git a/Tests/NostrSDKTests/Fixtures/time_based_calendar_event.json b/Tests/NostrSDKTests/Fixtures/time_based_calendar_event.json index ecc3369..97c0344 100644 --- a/Tests/NostrSDKTests/Fixtures/time_based_calendar_event.json +++ b/Tests/NostrSDKTests/Fixtures/time_based_calendar_event.json @@ -1,15 +1,15 @@ { - "id": "091455f5c9509655e3a4f68f98e807349ac0b5525506b22978566a0bb0f3ced1", + "id": "818854c3ff09ac5a2c538cba81d911e59f929dcc5531f61ac92278093d101f1b", "pubkey": "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", - "created_at": 1700320270, + "created_at": 1702833417, "kind": 31923, "tags": [ [ "d", - "9CD6DE6C-F8D9-44FB-B948-CB5A42434F8F" + "798F1F69-1DE3-4623-8DCC-FAF9B773E72B" ], [ - "name", + "title", "Flight from New York (JFK) to San José, Costa Rica (SJO)" ], [ @@ -66,5 +66,5 @@ ] ], "content": "Flight to Nostrica", - "sig": "57cdb0735645a7ff7112c2d863c425a75e82b540412117609c1c32bee833622a82acacd836b2790f0f08082e2700cdf1ac363c2aae1db87e613824fea2907845" -} \ No newline at end of file + "sig": "c2aa36b07c4df050d637dd2be770767c67621e7d87179f9f1e5ef118543328ed238afbd6b33317a61178205b75e6ecb0a61ea4cf6c657a7da0e4cea4842d4c01" +} diff --git a/Tests/NostrSDKTests/Fixtures/time_based_calendar_event_deprecated.json b/Tests/NostrSDKTests/Fixtures/time_based_calendar_event_deprecated.json new file mode 100644 index 0000000..ecc3369 --- /dev/null +++ b/Tests/NostrSDKTests/Fixtures/time_based_calendar_event_deprecated.json @@ -0,0 +1,70 @@ +{ + "id": "091455f5c9509655e3a4f68f98e807349ac0b5525506b22978566a0bb0f3ced1", + "pubkey": "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "created_at": 1700320270, + "kind": 31923, + "tags": [ + [ + "d", + "9CD6DE6C-F8D9-44FB-B948-CB5A42434F8F" + ], + [ + "name", + "Flight from New York (JFK) to San José, Costa Rica (SJO)" + ], + [ + "start", + "1679062500" + ], + [ + "end", + "1679067720" + ], + [ + "start_tzid", + "America/New_York" + ], + [ + "end_tzid", + "America/Costa_Rica" + ], + [ + "location", + "John F. Kennedy International Airport, Queens, NY 11430, USA" + ], + [ + "g", + "dr5x1p57bg9e" + ], + [ + "p", + "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "wss://relay.nostrsdk.com", + "organizer" + ], + [ + "p", + "9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340", + "wss://relay.nostrsdk.com", + "attendee" + ], + [ + "t", + "flights" + ], + [ + "t", + "costarica" + ], + [ + "r", + "https://nostrica.com/" + ], + [ + "r", + "https://docs.google.com/document/d/1Gsv09gfuwhqhQerIkxeYQ7iOTjOHUC5oTnL2KKyHpR8/edit" + ] + ], + "content": "Flight to Nostrica", + "sig": "57cdb0735645a7ff7112c2d863c425a75e82b540412117609c1c32bee833622a82acacd836b2790f0f08082e2700cdf1ac363c2aae1db87e613824fea2907845" +} \ No newline at end of file