From aaa4b0de975986f083a469514feb194635139da5 Mon Sep 17 00:00:00 2001 From: kieran Date: Thu, 27 Feb 2025 15:29:28 +0000 Subject: [PATCH] feat: support rendering kind 20,21,22 feat: reply to non-text-note as kind 1111 --- nap.yaml | 2 +- .../Components/Event/Create/NoteCreator.tsx | 5 +-- .../src/Components/Event/EventComponent.tsx | 17 ++++++++- .../app/src/Components/Event/Note/Note.tsx | 9 ++++- packages/app/src/Feed/ThreadFeed.ts | 25 ++++++++----- packages/app/src/Utils/Thread/ChainKey.tsx | 13 ++++--- packages/system/src/event-kind.ts | 4 +++ packages/system/src/event-publisher.ts | 13 +++++-- packages/system/src/impl/nip22.ts | 35 +++++++++++++++++++ packages/system/src/index.ts | 1 + packages/system/src/nostr-link.ts | 35 ++++++++++++++++++- packages/system/src/utils.ts | 9 ++++- 12 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 packages/system/src/impl/nip22.ts diff --git a/nap.yaml b/nap.yaml index b0bd7f15..fb1996d1 100644 --- a/nap.yaml +++ b/nap.yaml @@ -8,4 +8,4 @@ repository: "https://github.com/v0l/snort" license: "MIT" tags: - "social" - - "twitter" \ No newline at end of file + - "twitter" diff --git a/packages/app/src/Components/Event/Create/NoteCreator.tsx b/packages/app/src/Components/Event/Create/NoteCreator.tsx index 248299cd..cfe76b5a 100644 --- a/packages/app/src/Components/Event/Create/NoteCreator.tsx +++ b/packages/app/src/Components/Event/Create/NoteCreator.tsx @@ -139,7 +139,6 @@ export function NoteCreator() { extraTags ??= []; extraTags.push(["content-warning", note.sensitive]); } - const kind = note.pollOptions ? EventKind.Polls : EventKind.TextNote; if (note.pollOptions) { extraTags ??= []; extraTags.push(...note.pollOptions.map((a, i) => ["poll_option", i.toString(), a])); @@ -179,7 +178,9 @@ export function NoteCreator() { const hk = (eb: EventBuilder) => { extraTags?.forEach(t => eb.tag(t)); note.extraTags?.forEach(t => eb.tag(t)); - eb.kind(kind); + if (note.pollOptions) { + eb.kind(EventKind.Polls); + } return eb; }; const ev = note.replyTo diff --git a/packages/app/src/Components/Event/EventComponent.tsx b/packages/app/src/Components/Event/EventComponent.tsx index 2e68c66b..cd46ac93 100644 --- a/packages/app/src/Components/Event/EventComponent.tsx +++ b/packages/app/src/Components/Event/EventComponent.tsx @@ -1,6 +1,6 @@ import "./EventComponent.css"; -import { EventKind, NostrEvent, TaggedNostrEvent } from "@snort/system"; +import { EventKind, NostrEvent, parseIMeta, TaggedNostrEvent } from "@snort/system"; import { memo, ReactNode } from "react"; import PubkeyList from "@/Components/Embed/PubkeyList"; @@ -75,6 +75,21 @@ export default memo(function EventComponent(props: NoteProps) { case 9041: // Assuming 9041 is a valid EventKind content = ; break; + case EventKind.Photo: + case EventKind.Video: + case EventKind.ShortVideo: { + // append media to note as if kind1 post + const media = parseIMeta(ev.tags); + // Sometimes we cann call this twice so check the URL's are not already + // in the content + const urls = Object.entries(media ?? {}).map(([k]) => k); + if (!urls.every(u => ev.content.includes(u))) { + const newContent = ev.content + " " + urls.join("\n"); + props.data.content = newContent; + } + content = ; + break; + } case EventKind.LongFormTextNote: content = ( ({ maxSize: 300 }); export function Note(props: NoteProps) { diff --git a/packages/app/src/Feed/ThreadFeed.ts b/packages/app/src/Feed/ThreadFeed.ts index 1c52d9c7..ced5002e 100644 --- a/packages/app/src/Feed/ThreadFeed.ts +++ b/packages/app/src/Feed/ThreadFeed.ts @@ -1,4 +1,4 @@ -import { EventKind, Nip10, NostrLink, RequestBuilder } from "@snort/system"; +import { EventKind, Nip10, Nip22, NostrLink, RequestBuilder } from "@snort/system"; import { SnortContext, useRequestBuilder } from "@snort/system-react"; import { useContext, useEffect, useMemo, useState } from "react"; @@ -34,7 +34,7 @@ export default function useThreadFeed(link: NostrLink) { for (const v of Object.values(grouped)) { sub .withFilter() - .kinds([EventKind.TextNote]) + .kinds([EventKind.TextNote, EventKind.Comment]) .replyToLink(v) .relay(rootRelays ?? []); } @@ -48,7 +48,9 @@ export default function useThreadFeed(link: NostrLink) { const links = store .map(a => [ NostrLink.fromEvent(a), - ...a.tags.filter(a => a[0] === "e" || a[0] === "a").map(v => NostrLink.fromTag(v)), + ...a.tags + .filter(a => a[0] === "e" || a[0] === "a" || a[0] === "E" || a[0] === "A") + .map(v => NostrLink.fromTag(v)), ]) .flat(); setAllEvents(links); @@ -56,14 +58,19 @@ export default function useThreadFeed(link: NostrLink) { // load the thread structure from the current note const current = store.find(a => link.matchesEvent(a)); if (current) { - const t = Nip10.parseThread(current); - if (t) { - const rootOrReplyAsRoot = t?.root ?? t?.replyTo; - if (rootOrReplyAsRoot) { - setRoot(rootOrReplyAsRoot); + if (current.kind === EventKind.TextNote) { + const t = Nip10.parseThread(current); + if (t) { + const rootOrReplyAsRoot = t?.root ?? t?.replyTo; + if (rootOrReplyAsRoot) { + setRoot(rootOrReplyAsRoot); + } + } else { + setRoot(link); } } else { - setRoot(link); + const root = Nip22.rootScopeOf(current); + setRoot(NostrLink.fromTag(root)); } } } diff --git a/packages/app/src/Utils/Thread/ChainKey.tsx b/packages/app/src/Utils/Thread/ChainKey.tsx index 31d2998d..a0b53070 100644 --- a/packages/app/src/Utils/Thread/ChainKey.tsx +++ b/packages/app/src/Utils/Thread/ChainKey.tsx @@ -1,4 +1,4 @@ -import { Nip10, NostrLink, TaggedNostrEvent } from "@snort/system"; +import { EventKind, Nip10, NostrLink, TaggedNostrEvent } from "@snort/system"; /** * Get the chain key as a reply event @@ -6,9 +6,14 @@ import { Nip10, NostrLink, TaggedNostrEvent } from "@snort/system"; * ie. Get the key for which this event is replying to */ export function replyChainKey(ev: TaggedNostrEvent) { - const t = Nip10.parseThread(ev); - const tag = t?.replyTo ?? t?.root; - return tag?.tagKey; + if (ev.kind !== EventKind.Comment) { + const t = Nip10.parseThread(ev); + const tag = t?.replyTo ?? t?.root; + return tag?.tagKey; + } else { + const k = ev.tags.find(t => ["e", "a", "i"].includes(t[0])); + return k?.[1]; + } } /** diff --git a/packages/system/src/event-kind.ts b/packages/system/src/event-kind.ts index 8bbbbaee..f30ce316 100644 --- a/packages/system/src/event-kind.ts +++ b/packages/system/src/event-kind.ts @@ -12,12 +12,16 @@ const enum EventKind { SimpleChatMessage = 9, // NIP-29 SealedRumor = 13, // NIP-59 ChatRumor = 14, // NIP-24 + Photo = 20, // NIP-68 + Video = 21, // NIP-71 + ShortVideo = 22, // NIP-71 PublicChatChannel = 40, // NIP-28 PublicChatMetadata = 41, // NIP-28 PublicChatMessage = 42, // NIP-28 PublicChatMuteMessage = 43, // NIP-28 PublicChatMuteUser = 44, // NIP-28 SnortSubscriptions = 1000, // NIP-XX + Comment = 1111, // NIP-22 Polls = 6969, // NIP-69 GiftWrap = 1059, // NIP-59 FileHeader = 1063, // NIP-94 diff --git a/packages/system/src/event-publisher.ts b/packages/system/src/event-publisher.ts index a39fc50c..fb143596 100644 --- a/packages/system/src/event-publisher.ts +++ b/packages/system/src/event-publisher.ts @@ -25,6 +25,7 @@ import { EventBuilder } from "./event-builder"; import { findTag } from "./utils"; import { Nip7Signer } from "./impl/nip7"; import { Nip10 } from "./impl/nip10"; +import { Nip22 } from "./impl/nip22"; type EventBuilderHook = (ev: EventBuilder) => EventBuilder; @@ -195,12 +196,19 @@ export class EventPublisher { /** * Reply to a note + * + * Replies to kind 1 notes are kind 1, otherwise kind 1111 */ async reply(replyTo: TaggedNostrEvent, msg: string, fnExtra?: EventBuilderHook) { - const eb = this.#eb(EventKind.TextNote); + const kind = replyTo.kind === EventKind.TextNote ? EventKind.TextNote : EventKind.Comment; + const eb = this.#eb(kind); eb.content(msg); - Nip10.replyTo(replyTo, eb); + if (kind === EventKind.TextNote) { + Nip10.replyTo(replyTo, eb); + } else { + Nip22.replyTo(replyTo, eb); + } eb.processContent(); fnExtra?.(eb); return await this.#sign(eb); @@ -211,6 +219,7 @@ export class EventPublisher { eb.content(content); eb.tag(unwrap(NostrLink.fromEvent(evRef).toEventTag())); eb.tag(["p", evRef.pubkey]); + eb.tag(["k", evRef.kind.toString()]); return await this.#sign(eb); } diff --git a/packages/system/src/impl/nip22.ts b/packages/system/src/impl/nip22.ts new file mode 100644 index 00000000..a07d2b28 --- /dev/null +++ b/packages/system/src/impl/nip22.ts @@ -0,0 +1,35 @@ +import { findTag } from "../utils"; +import { EventBuilder, NostrEvent, NostrLink, NostrPrefix } from "../index"; + +export class Nip22 { + /** + * Get the root scope tag (E/A/I) or + * create a root scope tag from the provided event + */ + static rootScopeOf(other: NostrEvent) { + const linkOther = NostrLink.fromEvent(other); + return other.tags.find(t => ["E", "A", "I"].includes(t[0])) ?? linkOther.toEventTagNip22(true)!; + } + + static replyTo(other: NostrEvent, eb: EventBuilder) { + const linkOther = NostrLink.fromEvent(other); + const rootScope = Nip22.rootScopeOf(other); + const rootKind = ["K", findTag(other, "K") ?? other.kind.toString()]; + const rootAuthor = ["P", findTag(other, "P") ?? other.pubkey]; + + const replyScope = linkOther.toEventTagNip22(false); + const replyKind = ["k", other.kind.toString()]; + const replyAuthor = ["p", other.pubkey]; + + if (rootScope === undefined || replyScope === undefined) { + throw new Error("RootScope or ReplyScope are undefined!"); + } + + eb.tag(rootScope); + eb.tag(rootKind); + eb.tag(rootAuthor); + eb.tag(replyScope); + eb.tag(replyKind); + eb.tag(replyAuthor); + } +} diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index 6e197793..13394f20 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -30,6 +30,7 @@ export * from "./encryption"; export * from "./impl/nip4"; export * from "./impl/nip7"; export * from "./impl/nip10"; +export * from "./impl/nip22"; export * from "./impl/nip44"; export * from "./impl/nip46"; export * from "./impl/nip57"; diff --git a/packages/system/src/nostr-link.ts b/packages/system/src/nostr-link.ts index f96581b7..995d05c4 100644 --- a/packages/system/src/nostr-link.ts +++ b/packages/system/src/nostr-link.ts @@ -114,6 +114,32 @@ export class NostrLink implements ToNostrEventTag { } } + /** + * Create an event tag from this link as per NIP-22 (no marker position) + */ + toEventTagNip22(root?: boolean) { + // emulate root flag by root marker + root ??= this.marker === "root"; + + const suffix: Array = []; + if (this.relays && this.relays.length > 0) { + suffix.push(this.relays[0]); + } + if (this.type === NostrPrefix.PublicKey || this.type === NostrPrefix.Profile) { + return [root ? "P" : "p", this.id, ...suffix]; + } else if (this.type === NostrPrefix.Note || this.type === NostrPrefix.Event) { + if (this.author) { + if (suffix[0] === undefined) { + suffix.push(""); // empty relay hint + } + suffix.push(this.author); + } + return [root ? "E" : "e", this.id, ...suffix]; + } else if (this.type === NostrPrefix.Address) { + return [root ? "A" : "a", `${this.kind}:${this.author}:${this.id}`, ...suffix]; + } + } + matchesEvent(ev: NostrEvent) { if (this.type === NostrPrefix.Address) { const dTag = findTag(ev, "d"); @@ -194,12 +220,19 @@ export class NostrLink implements ToNostrEventTag { static fromTag(tag: Array, author?: string, kind?: number) { const relays = tag.length > 2 ? [tag[2]] : undefined; switch (tag[0]) { + case "E": { + return new NostrLink(NostrPrefix.Event, tag[1], kind, author ?? tag[3], relays, "root"); + } case "e": { return new NostrLink(NostrPrefix.Event, tag[1], kind, author ?? tag[4], relays, tag[3]); } case "p": { return new NostrLink(NostrPrefix.Profile, tag[1], kind, author, relays); } + case "A": { + const [kind, author, dTag] = tag[1].split(":"); + return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays, "root"); + } case "a": { const [kind, author, dTag] = tag[1].split(":"); return new NostrLink(NostrPrefix.Address, dTag, Number(kind), author, relays, tag[3]); @@ -242,7 +275,7 @@ export class NostrLink implements ToNostrEventTag { let relays = "relays" in ev ? ev.relays : undefined; const eventRelays = removeUndefined( ev.tags - .filter(a => a[0] === "relays" || a[0] === "relay" || a[0] === "r") + .filter(a => a[0] === "relays" || a[0] === "relay" || (a[0] === "r" && ev.kind == EventKind.Relays)) .flatMap(a => a.slice(1).map(b => sanitizeRelayUrl(b))), ); relays = appendDedupe(relays, eventRelays); diff --git a/packages/system/src/utils.ts b/packages/system/src/utils.ts index 51d393e9..34801ecd 100644 --- a/packages/system/src/utils.ts +++ b/packages/system/src/utils.ts @@ -1,6 +1,6 @@ import { equalProp } from "@snort/shared"; import { FlatReqFilter } from "./query-optimizer"; -import { IMeta, NostrEvent, ReqFilter } from "./nostr"; +import { NostrEvent, ReqFilter } from "./nostr"; export function findTag(e: NostrEvent, tag: string) { const maybeTag = e.tags.find(evTag => { @@ -9,6 +9,13 @@ export function findTag(e: NostrEvent, tag: string) { return maybeTag && maybeTag[1]; } +export function findWholeTag(e: NostrEvent, tag: string) { + const maybeTag = e.tags.find(evTag => { + return evTag[0] === tag; + }); + return maybeTag; +} + export function reqFilterEq(a: FlatReqFilter | ReqFilter, b: FlatReqFilter | ReqFilter): boolean { return ( equalProp(a.ids, b.ids) &&