Skip to content

Commit

Permalink
Amend NIP-52 to support calendars and RSVPs
Browse files Browse the repository at this point in the history
  • Loading branch information
tyiu committed Dec 20, 2023
1 parent 892acad commit 4d2fe32
Show file tree
Hide file tree
Showing 19 changed files with 781 additions and 86 deletions.
94 changes: 78 additions & 16 deletions Sources/NostrSDK/EventCreating.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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] = []

Expand All @@ -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 {
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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)))
]

Expand All @@ -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 {
Expand All @@ -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)
}
}
20 changes: 17 additions & 3 deletions Sources/NostrSDK/EventKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -104,7 +114,9 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable {
.muteList,
.longformContent,
.dateBasedCalendarEvent,
.timeBasedCalendarEvent
.timeBasedCalendarEvent,
.calendar,
.calendarEventRSVP
]

public init(rawValue: Int) {
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions Sources/NostrSDK/Events/Calendars/CalendarEventRSVP.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
41 changes: 41 additions & 0 deletions Sources/NostrSDK/Events/Calendars/CalendarEventRSVPFreebusy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading

0 comments on commit 4d2fe32

Please sign in to comment.