Skip to content

Commit

Permalink
Add support for NIP-52 date-based and time-based calendar events
Browse files Browse the repository at this point in the history
  • Loading branch information
tyiu committed Nov 18, 2023
1 parent 6733d41 commit 9a94a79
Show file tree
Hide file tree
Showing 18 changed files with 789 additions and 4 deletions.
106 changes: 106 additions & 0 deletions Sources/NostrSDK/EventCreating.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,110 @@ public extension EventCreating {
]
return try ReportEvent(content: additionalInformation, tags: tags, signedBy: keypair)
}

func dateBasedCalendarEvent(withName name: String, description: String = "", start: DateComponents, end: DateComponents? = nil, location: String? = nil, geohash: String? = nil, participants: [CalendarEventParticipant]? = nil, hashtags: [String]? = nil, references: [URL]? = nil, signedBy keypair: Keypair) throws -> DateBasedCalendarEventNostrEvent {

Check failure on line 229 in Sources/NostrSDK/EventCreating.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Cyclomatic Complexity Violation: Function should have complexity 10 or less; currently complexity is 11 (cyclomatic_complexity)

guard start.isValidDate(in: Calendar(identifier: .iso8601)) else {
throw EventCreatingError.invalidInput
}

guard end?.isValidDate(in: Calendar(identifier: .iso8601)) == true else {
throw EventCreatingError.invalidInput
}

guard let startDate = start.date else {
throw EventCreatingError.invalidInput
}

let endDate = end?.date
if endDate != nil {
guard let endDate, startDate < endDate else {
throw EventCreatingError.invalidInput
}
}

let iso8601DateFormatter = ISO8601DateFormatter()
iso8601DateFormatter.formatOptions = .withFullDate

var tags: [Tag] = [
Tag(name: .unknown("d"), value: UUID().uuidString),
Tag(name: .unknown("name"), value: name),
Tag(name: .unknown("start"), value: iso8601DateFormatter.string(from: startDate))
]

if let endDate {
tags.append(Tag(name: .unknown("end"), value: iso8601DateFormatter.string(from: endDate)))
}

if let location {
tags.append(Tag(name: .unknown("location"), value: location))
}

if let geohash {
tags.append(Tag(name: .unknown("g"), value: geohash))
}

if let participants, !participants.isEmpty {
tags += participants.map { $0.tag }
}

if let hashtags, !hashtags.isEmpty {
tags += hashtags.map { Tag(name: .hashtag, value: $0) }
}

if let references, !references.isEmpty {
tags += references.map { Tag(name: .unknown("r"), value: $0.absoluteString) }
}

return try DateBasedCalendarEventNostrEvent(content: description, tags: tags, signedBy: keypair)
}

func timeBasedCalendarEvent(withName name: String, description: String = "", start: Date, end: 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 -> TimeBasedCalendarEventNostrEvent {

if let end {
guard start < end else {
throw EventCreatingError.invalidInput
}
}

var tags: [Tag] = [
Tag(name: .unknown("d"), value: UUID().uuidString),
Tag(name: .unknown("name"), value: name),
Tag(name: .unknown("start"), value: String(Int64(start.timeIntervalSince1970)))
]

if let end {
tags.append(Tag(name: .unknown("end"), value: String(Int64(end.timeIntervalSince1970))))
}

if let startTimeZone {
tags.append(Tag(name: .unknown("start_tzid"), value: startTimeZone.identifier))
}

if let endTimeZone {
tags.append(Tag(name: .unknown("end_tzid"), value: endTimeZone.identifier))
}

if let location {
tags.append(Tag(name: .unknown("location"), value: location))
}

if let geohash {
tags.append(Tag(name: .unknown("g"), value: geohash))
}

if let participants {
tags += participants.map { $0.tag }
}

if let hashtags {
tags += hashtags.map { Tag(name: .hashtag, value: $0) }
}

if let references {
tags += references.map { Tag(name: .unknown("r"), value: $0.absoluteString) }
}

return try TimeBasedCalendarEventNostrEvent(content: description, tags: tags, signedBy: keypair)
}
}
12 changes: 10 additions & 2 deletions Sources/NostrSDK/EventKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable {
///
/// See [NIP-56](https://github.com/nostr-protocol/nips/blob/b4cdc1a73d415c79c35655fa02f5e55cd1f2a60c/56.md#nip-56).
case report


case dateBasedCalendarEvent

case timeBasedCalendarEvent

/// 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 @@ -82,7 +86,9 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable {
.repost,
.reaction,
.genericRepost,
.report
.report,
.dateBasedCalendarEvent,
.timeBasedCalendarEvent
]

public init(rawValue: Int) {
Expand All @@ -102,6 +108,8 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable {
case .reaction: return 7
case .genericRepost: return 16
case .report: return 1984
case .dateBasedCalendarEvent: return 31922
case .timeBasedCalendarEvent: return 31923
case let .unknown(value): return value
}
}
Expand Down
69 changes: 69 additions & 0 deletions Sources/NostrSDK/Events/Calendars/CalendarEventParticipant.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// CalendarEventParticipant.swift
//
//
// Created by Terry Yiu on 11/15/23.
//

import Foundation

public struct CalendarEventParticipant: PubkeyTag, RelayTagParameter, Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.tag == rhs.tag
}

public let tag: Tag

public var pubkey: PublicKey? {
PublicKey(hex: tag.value)
}

public var relay: URL? {
guard !tag.otherParameters.isEmpty else {
return nil
}

let relayString = tag.otherParameters[0]
guard !relayString.isEmpty else {
return nil
}

let components = URLComponents(string: relayString)
guard components?.scheme == "wss" || components?.scheme == "ws" else {
return nil
}
return components?.url
}

public var role: String? {
guard tag.otherParameters.count >= 2 else {
return nil
}

return tag.otherParameters[1]
}

public init?(tag: Tag) {
guard tag.name == .pubkey else {
return nil
}

self.tag = tag
}

public init(pubkey: PublicKey, relay: URL? = nil, role: String? = nil) {
var otherParameters: [String] = [relay?.absoluteString ?? ""]
if let role, !role.isEmpty {
otherParameters.append(role)
}

tag = Tag(name: .pubkey, value: pubkey.hex, otherParameters: otherParameters)
}
}

public protocol CalendarEventParticipantInterpreting: NostrEvent {}
public extension CalendarEventParticipantInterpreting {
var participants: [CalendarEventParticipant] {
tags.filter { $0.name == .pubkey }.compactMap { CalendarEventParticipant(tag: $0) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// DateBasedCalendarEventNostrEvent.swift
//
//
// Created by Terry Yiu on 11/13/23.
//

import Foundation

/// Date-based calendar event 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.
/// See [NIP-52](https://github.com/nostr-protocol/nips/blob/master/52.md).
public final class DateBasedCalendarEventNostrEvent: NostrEvent, CalendarEventParticipantInterpreting, HashtagInterpreting, ReferenceTagInterpreting {
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: .dateBasedCalendarEvent, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

public var uuid: String? {
tags.first { $0.name.rawValue == "d" }?.value
}

public var name: String? {
tags.first { $0.name.rawValue == "name" }?.value
}

/// Start date represented by ``DateComponents`` in the calendar context of ``Calendar.Identifier.iso8601``, with `year`, `month`, and `day` populated.
/// `nil` is returned if the backing `start` tag is malformed.
public var start: DateComponents? {
guard let startString = tags.first(where: { $0.name.rawValue == "start" })?.value else {
return nil
}

return startString.dateStringAsDateComponents
}

/// End date represented by ``DateComponents`` in the calendar context of ``Calendar.Identifier.iso8601``, with `year`, `month`, and `day` populated.
/// `nil` is returned if the backing `end` tag is malformed.
public var end: DateComponents? {
guard let startString = tags.first(where: { $0.name.rawValue == "end" })?.value else {
return nil
}

return startString.dateStringAsDateComponents
}

public var location: String? {
tags.first { $0.name.rawValue == "location" }?.value
}

public var geohash: String? {
tags.first { $0.name.rawValue == "g" }?.value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//
// TimeBasedCalendarEventEvent.swift
//
//
// Created by Terry Yiu on 11/16/23.
//

import Foundation

/// Time-based calendar event spans between a start time and end time.
///
/// Unlike the term `calendar event` specific to NIP-52, the term `event` is used broadly in all the NIPs to describe any Nostr event.
/// That is the reason why the word `event` appears twice. It is not a typo.
public final class TimeBasedCalendarEventNostrEvent: NostrEvent, CalendarEventParticipantInterpreting, HashtagInterpreting, ReferenceTagInterpreting {
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: .timeBasedCalendarEvent, content: content, tags: tags, createdAt: createdAt, signedBy: keypair)
}

public var uuid: String? {
tags.first { $0.name.rawValue == "d" }?.value
}

public var name: String? {
tags.first { $0.name.rawValue == "name" }?.value
}

/// Start timestamp of calendar event represented by ``Date``.
/// `nil` is returned if the backing `start` tag is malformed.
public var start: Date? {
guard let startString = tags.first(where: { $0.name.rawValue == "start" })?.value, let startSeconds = Int(startString) else {
return nil
}

return Date(timeIntervalSince1970: TimeInterval(startSeconds))
}

/// End timestamp represented by ``Date``.
/// `nil` is returned if the backing `end` tag is malformed.
public var end: Date? {
guard let endString = tags.first(where: { $0.name.rawValue == "end" })?.value, let endSeconds = Int(endString) else {
return nil
}

return Date(timeIntervalSince1970: TimeInterval(endSeconds))
}

public var startTimeZone: TimeZone? {
guard let timeZoneIdentifier = tags.first(where: { $0.name.rawValue == "start_tzid" })?.value else {
return nil
}

return TimeZone(identifier: timeZoneIdentifier)
}

public var endTimeZone: TimeZone? {
guard let timeZoneIdentifier = tags.first(where: { $0.name.rawValue == "end_tzid" })?.value else {
return nil
}

return TimeZone(identifier: timeZoneIdentifier)
}

public var location: String? {
tags.first { $0.name.rawValue == "location" }?.value
}

public var geohash: String? {
tags.first { $0.name.rawValue == "g" }?.value
}
}

Check failure on line 80 in Sources/NostrSDK/Events/Calendars/TimeBasedCalendarEventNostrEvent.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Newline Violation: Files should have a single trailing newline (trailing_newline)
16 changes: 16 additions & 0 deletions Sources/NostrSDK/Events/Tags/HashtagInterpreting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// HashtagInterpreting.swift
//
//
// Created by Terry Yiu on 11/15/23.
//

import Foundation

public protocol HashtagInterpreting: NostrEvent {}
public extension HashtagInterpreting {
var hashtags: [String] {
tags.filter { $0.name == .hashtag }
.map { $0.value }
}
}
12 changes: 12 additions & 0 deletions Sources/NostrSDK/Events/Tags/PubkeyTag.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// PubkeyTag.swift
//
//
// Created by Terry Yiu on 11/15/23.
//

import Foundation

public protocol PubkeyTag {
var pubkey: PublicKey? { get }
}
Loading

0 comments on commit 9a94a79

Please sign in to comment.