Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds support for NIP-28 #178

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ name: Docs

on:
push:
branches:
- main
branches: [ "main" ]
workflow_dispatch:

# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: SwiftLint

on:
push:
branches: [ '**' ]
pull_request_target:
branches: [ '**' ]
branches: [ "main" ]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:

jobs:
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ name: Unit Tests

on:
push:
branches: [ '**' ]
pull_request_target:
branches: [ '**' ]
branches: [ "main" ]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:

jobs:
build-and-test:
runs-on: macos-latest
strategy:
matrix:
swift: ['5.8', '5.9', '5.10']
swift: ['5.8', '5.9', '5.10', '6.0']

steps:
- name: Checkout
Expand Down
35 changes: 35 additions & 0 deletions Sources/NostrSDK/EventKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
/// See [NIP-18](https://github.com/nostr-protocol/nips/blob/master/18.md#nip-18).
case genericRepost

/// Create a public chat channel.
/// See [NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-40-create-channel).
case channelCreation

/// Update a channel's public metadata.
/// See [NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-41-set-channel-metadata).
case channelMetadata

/// Send a text message to a channel.
/// See [NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-42-create-channel-message).
case channelMessage

/// User no longer wants to see a certain message.
/// See [NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-43-hide-message).
case channelHideMessage

/// User no longer wants to see messages from another user.
/// See [NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-44-mute-user).
case channelMuteUser

/// This kind of event wraps a `seal` event.
/// The wrapped seal is always encrypted to a receiver's pubkey using a random, one-time-use private key.
/// The gift wrap event tags should include any information needed to route the event to its intended recipient,
Expand Down Expand Up @@ -140,6 +160,11 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
.reaction,
.seal,
.genericRepost,
.channelCreation,
.channelMetadata,
.channelMessage,
.channelHideMessage,
.channelMuteUser,
.giftWrap,
.report,
.muteList,
Expand Down Expand Up @@ -173,6 +198,11 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
case .reaction: return 7
case .seal: return 13
case .genericRepost: return 16
case .channelCreation: return 40
case .channelMetadata: return 41
case .channelMessage: return 42
case .channelHideMessage: return 43
case .channelMuteUser: return 44
case .giftWrap: return 1059
case .report: return 1984
case .muteList: return 10000
Expand Down Expand Up @@ -200,6 +230,11 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha
case .reaction: return ReactionEvent.self
case .seal: return SealEvent.self
case .genericRepost: return GenericRepostEvent.self
case .channelCreation: return CreateChannelEvent.self
case .channelMetadata: return SetChannelMetadataEvent.self
case .channelMessage: return CreateChannelMessageEvent.self
case .channelHideMessage: return HideChannelMessageEvent.self
case .channelMuteUser: return MuteChannelUserEvent.self
case .giftWrap: return GiftWrapEvent.self
case .report: return ReportEvent.self
case .muteList: return MuteListEvent.self
Expand Down
4 changes: 2 additions & 2 deletions Sources/NostrSDK/EventSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ public enum EventSerializer {

let tagsString: String
if let tagsData = try? encoder.encode(tags) {
tagsString = String(decoding: tagsData, as: UTF8.self)
tagsString = String(data: tagsData, encoding: .utf8) ?? "[]"
} else {
tagsString = "[]"
}

let contentString: String
if let contentData = try? encoder.encode(content) {
contentString = String(decoding: contentData, as: UTF8.self)
contentString = String(data: contentData, encoding: .utf8) ?? "\"\""
} else {
contentString = "\"\""
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/NostrSDK/Events/BookmarksListEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ public extension EventCreating {
var encryptedContent: String?
if !privateTags.isEmpty {
let rawPrivateTags = privateTags.map { $0.raw }
if let unencryptedData = try? JSONSerialization.data(withJSONObject: rawPrivateTags) {
let unencryptedContent = String(decoding: unencryptedData, as: UTF8.self)
if let unencryptedData = try? JSONSerialization.data(withJSONObject: rawPrivateTags),
let unencryptedContent = String(data: unencryptedData, encoding: .utf8) {
encryptedContent = try legacyEncrypt(content: unencryptedContent,
privateKey: keypair.privateKey,
publicKey: keypair.publicKey)
Expand Down
45 changes: 45 additions & 0 deletions Sources/NostrSDK/Events/Channels/ChannelMetadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// ChannelMetadata.swift
//
//
// Created by Konstantin Yurchenko, Jr on 9/20/24.
//

import Foundation

/// A structure that describes channel.
///
/// See [NIP-28 Specification](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-40-create-channel).
public struct ChannelMetadata: Codable {
/// Channel name
public let name: String?
/// Channel desctription
public let about: String?
/// URL of channel picture
public let picture: String?
/// List of relays to download and broadcast events to
public let relays: [String]?

enum CodingKeys: String, CodingKey {
case name
case about
case picture
case relays
}

public init(name: String? = nil, about: String? = nil, picture: String? = nil, relays: [String] = []) {
self.name = name
self.about = about
self.picture = picture
self.relays = relays
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

name = try container.decodeIfPresent(String.self, forKey: .name)
about = try container.decodeIfPresent(String.self, forKey: .about)
picture = try container.decodeIfPresent(String.self, forKey: .picture)
relays = try container.decodeIfPresent([String].self, forKey: .relays)
}
}
76 changes: 76 additions & 0 deletions Sources/NostrSDK/Events/Channels/CreateChannelEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// CreateChannelEvent.swift
//
//
// Created by Konstantin Yurchenko, Jr on 9/11/24.
//

import Foundation

/// Create a public chat channel.
/// See [NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-40-create-channel).
public class CreateChannelEvent: NostrEvent {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to expose the basic channel metadata (name, about, picture and relays) properties.

Take a look at https://github.com/nostr-sdk/nostr-sdk-ios/blob/main/Sources/NostrSDK/Events/MetadataEvent.swift as it also does JSON encoding and decoding for the kind 0 event.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I make a MetadataChannel class here or what?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a separate file, you could create a ChannelMetadata struct with the properties. Then, you could create a ChannelMetadataInterpreting protocol that decodes the JSON.

Kinds 40 CreateChannelEvent and kind 41 SetChannelMetadataEvent could extend from ChannelMetadataInterpreting.

Something like this, it'll be pretty similar to what we do with kind 0:

public struct ChannelMetadata: Codable {
    public let name: String?
    public let about: String?
    ...

    enum CodingKeys: String, CodingKey {
        ...
    }
}

public protocol ChannelMetadataInterpreting: NostrEvent {}
public extension ChannelMetadataInterpreting {
    public var channelMetadata: ChannelMetadata {
        guard let data = content.data(using: .utf8) else {
            return nil
        }
        return try? JSONDecoder().decode(ChannelMetadata.self, from: data)
    }
}


public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}

@available(*, unavailable, message: "This initializer is unavailable for this class.")
required 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)
}

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

@available(*, unavailable, message: "This initializer is unavailable for this class.")
override init(id: String, pubkey: String, createdAt: Int64, kind: EventKind, tags: [Tag], content: String, signature: String?) {
super.init(id: id, pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content, signature: signature)
}

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

class var kind: EventKind {
.channelCreation
}
}

public extension EventCreating {
func createChannelEvent(withContent content: String, signedBy keypair: Keypair) throws -> CreateChannelEvent {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in the process of deprecating the EventCreating protocol actually, and moving towards a Builder pattern. The former is too rigid for building Nostr events, whereas the latter gives us a lot of flexibility.

See https://github.com/nostr-sdk/nostr-sdk-ios/pull/175/files#diff-c87e105b65b1d7c9512acf2ee8c7e3fc7582648811025c959a37b36d97403b67R170 as an example.

Also, it would be better to have an API where the developer can specify the basic channel metadata (name, about, picture and relays) rather than needing to enter in raw JSON in content.
https://github.com/nostr-protocol/nips/blob/master/28.md#kind-40-create-channel

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, @tyiu, thanks for your time and looking over my commit!

Yes, I am on board with the later. The interface in this PR needs further definition and polish.
I created this PR because I am using your SDK with my project (SwiftUI/Swift 5). Thanks for putting it together!
So far, I was able to implement and successfully field test Encrypted Message, Group Chat and Key Management features.
As far as, my PR goes I will clean it up soon for the linter. I am thinking doing it in parallel while I’m working on replies for my chat application.

I don't quite understand what you mean by ‘deprecating EventCreating protocol’. I do use it a lot but I did find it somewhat rigid.
I also think there needs to be some better way to manage subscriptions. I do have them on about 5 Views on my app. Yikes...! 🦟

https://github.com/SkatePay/skatepay/blob/main/SkateConnect/SkateConnect/Views/Skatepark/Lobby/SpotFeed/ChannelFeed.swift#L145

Regards!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I say deprecating the EventCreating protocol in favor of the Builder protocol, I mean it would look something like this when creating this event:

try CreateChannelEvent.Builder()
    .channelMetadata(channelMetadata, merging: rawChannelMetadata)
    .build(signedBy: keypair)

Take a look at what we do here for kind 0, as an example:

public extension MetadataEvent {
/// Builder of ``MetadataEvent``.
final class Builder: NostrEvent.Builder<MetadataEvent>, CustomEmojiBuilding {
public init() {
super.init(kind: .metadata)
}
/// Sets the user metadata by merging ``UserMetadata`` with a dictionary of raw metadata.
///
/// - Parameters:
/// - userMetadata: The ``UserMetadata`` to set.
/// - rawUserMetadata: The dictionary of raw metadata to set that can contain fields unknown to any implemented NIPs.
///
/// > Note: If `rawUserMetadata` has fields that conflict with `userMetadata`, `userMetadata` fields take precedence.
public final func userMetadata(_ userMetadata: UserMetadata, merging rawUserMetadata: [String: Any] = [:]) throws -> Self {
let userMetadataAsData = try JSONEncoder().encode(userMetadata)
let allUserMetadataAsData: Data
if rawUserMetadata.isEmpty {
allUserMetadataAsData = userMetadataAsData
} else {
var userMetadataAsDictionary = try JSONSerialization.jsonObject(with: userMetadataAsData, options: []) as? [String: Any] ?? [:]
userMetadataAsDictionary.merge(rawUserMetadata) { (current, _) in current }
allUserMetadataAsData = try JSONSerialization.data(withJSONObject: userMetadataAsDictionary, options: .sortedKeys)
}
let allUserMetadataAsString = String(decoding: allUserMetadataAsData, as: UTF8.self)
content(allUserMetadataAsString)
return self
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@masterial Turns out the SwiftLint errors were on the main branch, due to how CI pulls in the latest SwiftLint version and it had some breaking changes in what it validates. I just fixed the issue on the main branch so the lint errors should go away after you pull it into your branch.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged, thanks for the heads up, @tyiu!
I plan to address other issues you've highlighted very soon

return try CreateChannelEvent(content: content, tags: [], signedBy: keypair)
}
}

public extension CreateChannelEvent {
/// Builder of ``CreateChannelEvent``.
final class Builder: NostrEvent.Builder<CreateChannelEvent> {
public init() {
super.init(kind: .channelCreation)
}

public final func channelMetadata(_ channelMetadata: ChannelMetadata, merging rawChannelMetadata: [String: Any] = [:]) throws -> Self {
let channelMetadataAsData = try JSONEncoder().encode(channelMetadata)

let allChannelMetadataAsData: Data
if rawChannelMetadata.isEmpty {
allChannelMetadataAsData = channelMetadataAsData
} else {
var channelMetadataAsDictionary = try JSONSerialization.jsonObject(with: channelMetadataAsData, options: []) as? [String: Any] ?? [:]
channelMetadataAsDictionary.merge(rawChannelMetadata) { (current, _) in current }
allChannelMetadataAsData = try JSONSerialization.data(withJSONObject: channelMetadataAsDictionary, options: .sortedKeys)
}

guard let allChannelMetadataAsString = String(data: allChannelMetadataAsData, encoding: .utf8) else {
throw EventCreatingError.invalidInput
}

content(allChannelMetadataAsString)

return self
}
}
}
62 changes: 62 additions & 0 deletions Sources/NostrSDK/Events/Channels/CreateChannelMessageEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// CreateChannelMessageEvent.swift
//
//
// Created by Konstantin Yurchenko, Jr on 9/11/24.
//

import Foundation

/// Send a text message to a channel.
/// See [NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-42-create-channel-message).
public class CreateChannelMessageEvent: NostrEvent {

public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}

@available(*, unavailable, message: "This initializer is unavailable for this class.")
required 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)
}

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

@available(*, unavailable, message: "This initializer is unavailable for this class.")
override init(id: String, pubkey: String, createdAt: Int64, kind: EventKind, tags: [Tag], content: String, signature: String?) {
super.init(id: id, pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content, signature: signature)
}

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

class var kind: EventKind {
.channelMessage
}
}

public extension EventCreating {
func createChannelMessageEvent(
withContent content: String,
eventId: String,
relayUrl: String,
hashtag: String? = nil,
signedBy keypair: Keypair
) throws -> CreateChannelMessageEvent {

var tags: [Tag] = [
Tag.pubkey(keypair.publicKey.hex),
Tag.event(eventId, otherParameters: [relayUrl, "root"]),
]

if let hashtag = hashtag {
tags.append(Tag.hashtag(hashtag))
}

return try CreateChannelMessageEvent(content: content, tags: tags, signedBy: keypair)
}
}
46 changes: 46 additions & 0 deletions Sources/NostrSDK/Events/Channels/HideChannelMessageEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// HideChannelMessageEvent.swift
//
//
// Created by Konstantin Yurchenko, Jr on 9/11/24.
//

import Foundation

/// User no longer wants to see a certain message.
/// See [NIP-28](https://github.com/nostr-protocol/nips/blob/master/28.md#kind-43-hide-message).
public class HideChannelMessageEvent: NostrEvent {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here for the reason metadata in the content field.


public required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}

@available(*, unavailable, message: "This initializer is unavailable for this class.")
required 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)
}

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

@available(*, unavailable, message: "This initializer is unavailable for this class.")
override init(id: String, pubkey: String, createdAt: Int64, kind: EventKind, tags: [Tag], content: String, signature: String?) {
super.init(id: id, pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content, signature: signature)
}

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

class var kind: EventKind {
.channelHideMessage
}
}

public extension EventCreating {
func hideChannelMessageEvent(withContent content: String, signedBy keypair: Keypair) throws -> HideChannelMessageEvent {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

return try HideChannelMessageEvent(content: content, tags: [], signedBy: keypair)
}
}
Loading