From 0b745cb40e63f07abace537d0667a5da34e86a1a Mon Sep 17 00:00:00 2001 From: reya Date: Wed, 17 Jan 2024 12:24:04 +0700 Subject: [PATCH] feat: add reply form --- packages/ark/src/ark.ts | 52 ++- .../ark/src/components/note/preview/image.tsx | 6 +- .../components/note/primitives/childReply.tsx | 12 +- .../src/components/note/primitives/reply.tsx | 24 +- .../src/components/note/primitives/repost.tsx | 8 +- .../src/components/note/primitives/text.tsx | 8 +- .../src/components/note/primitives/thread.tsx | 5 +- packages/ark/src/components/note/root.tsx | 5 +- packages/icons/src/navArrowDown.tsx | 28 +- packages/lume-column-thread/src/home.tsx | 2 +- packages/ui/src/editor/form.tsx | 12 +- packages/ui/src/editor/replyForm.tsx | 391 ++++++++++++++++++ packages/ui/src/replyList.tsx | 52 ++- packages/ui/src/routes/event.tsx | 2 +- 14 files changed, 512 insertions(+), 95 deletions(-) create mode 100644 packages/ui/src/editor/replyForm.tsx diff --git a/packages/ark/src/ark.ts b/packages/ark/src/ark.ts index 50773f2e..61b3d11c 100644 --- a/packages/ark/src/ark.ts +++ b/packages/ark/src/ark.ts @@ -228,31 +228,25 @@ export class Ark { return [...events]; } + public getCleanEventId(id: string) { + let eventId: string = id.replace("nostr:", "").split("'")[0].split(".")[0]; + + if ( + eventId.startsWith("nevent1") || + eventId.startsWith("note1") || + eventId.startsWith("naddr1") + ) { + const decode = nip19.decode(eventId); + if (decode.type === "nevent") eventId = decode.data.id; + if (decode.type === "note") eventId = decode.data; + } + + return eventId; + } + public async getEventById(id: string) { try { - let eventId: string = id - .replace("nostr:", "") - .split("'")[0] - .split(".")[0]; - - if ( - eventId.startsWith("nevent1") || - eventId.startsWith("note1") || - eventId.startsWith("naddr1") - ) { - const decode = nip19.decode(eventId); - - if (decode.type === "nevent") eventId = decode.data.id; - if (decode.type === "note") eventId = decode.data; - if (decode.type === "naddr") { - return await this.ndk.fetchEvent({ - kinds: [decode.data.kind], - "#d": [decode.data.identifier], - authors: [decode.data.pubkey], - }); - } - } - + const eventId = this.getCleanEventId(id); return await this.ndk.fetchEvent(eventId); } catch { throw new Error("event not found"); @@ -304,19 +298,19 @@ export class Ark { }; } - public async getThreads({ id }: { id: string }) { + public async getThreads(id: string) { + const eventId = this.getCleanEventId(id); const fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk)); + const relayUrls = [...this.ndk.pool.relays.values()].map( + (item) => item.url, + ); try { - const relayUrls = [...this.ndk.pool.relays.values()].map( - (item) => item.url, - ); - const rawEvents = (await fetcher.fetchAllEvents( relayUrls, { kinds: [NDKKind.Text], - "#e": [id], + "#e": [eventId], }, { since: 0 }, { sort: true }, diff --git a/packages/ark/src/components/note/preview/image.tsx b/packages/ark/src/components/note/preview/image.tsx index 17185eb4..db432175 100644 --- a/packages/ark/src/components/note/preview/image.tsx +++ b/packages/ark/src/components/note/preview/image.tsx @@ -48,12 +48,12 @@ export function ImagePreview({ url }: { url: string }) { diff --git a/packages/ark/src/components/note/primitives/childReply.tsx b/packages/ark/src/components/note/primitives/childReply.tsx index 348633b8..46b21b36 100644 --- a/packages/ark/src/components/note/primitives/childReply.tsx +++ b/packages/ark/src/components/note/primitives/childReply.tsx @@ -6,15 +6,13 @@ export function ChildReply({ }: { event: NDKEvent; rootEventId?: string }) { return ( - -
- + +
+
- -
- - + +
diff --git a/packages/ark/src/components/note/primitives/reply.tsx b/packages/ark/src/components/note/primitives/reply.tsx index 3e6185f4..b8a369f0 100644 --- a/packages/ark/src/components/note/primitives/reply.tsx +++ b/packages/ark/src/components/note/primitives/reply.tsx @@ -1,8 +1,8 @@ import { NavArrowDownIcon } from "@lume/icons"; import { NDKEventWithReplies } from "@lume/types"; +import { cn } from "@lume/utils"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; -import { twMerge } from "tailwind-merge"; import { Note } from ".."; import { ChildReply } from "./childReply"; @@ -16,35 +16,33 @@ export function Reply({ return ( - -
+ +
- -
+ +
{event.replies?.length > 0 ? ( -
+
{`${event.replies?.length} ${ event.replies?.length === 1 ? "reply" : "replies" }`}
- ) : null} + ) : ( +
+ )}
-
-
+
{event.replies?.length > 0 ? ( {event.replies?.map((childEvent) => ( diff --git a/packages/ark/src/components/note/primitives/repost.tsx b/packages/ark/src/components/note/primitives/repost.tsx index e98d975a..9f3e6a38 100644 --- a/packages/ark/src/components/note/primitives/repost.tsx +++ b/packages/ark/src/components/note/primitives/repost.tsx @@ -1,4 +1,5 @@ import { RepostIcon } from "@lume/icons"; +import { cn } from "@lume/utils"; import { NDKEvent, NostrEvent } from "@nostr-dev-kit/ndk"; import { useQuery } from "@tanstack/react-query"; import { Note } from ".."; @@ -69,7 +70,12 @@ export function RepostNote({ } return ( - +
diff --git a/packages/ark/src/components/note/primitives/text.tsx b/packages/ark/src/components/note/primitives/text.tsx index 18f85229..d3c6efd5 100644 --- a/packages/ark/src/components/note/primitives/text.tsx +++ b/packages/ark/src/components/note/primitives/text.tsx @@ -1,3 +1,4 @@ +import { cn } from "@lume/utils"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { Note } from ".."; @@ -7,7 +8,12 @@ export function TextNote({ }: { event: NDKEvent; className?: string }) { return ( - +
diff --git a/packages/ark/src/components/note/primitives/thread.tsx b/packages/ark/src/components/note/primitives/thread.tsx index 4e58bff7..5d8a5ae3 100644 --- a/packages/ark/src/components/note/primitives/thread.tsx +++ b/packages/ark/src/components/note/primitives/thread.tsx @@ -11,7 +11,7 @@ export function ThreadNote({ eventId }: { eventId: string }) { return ( - +
@@ -29,11 +29,10 @@ export function ThreadNote({ eventId }: { eventId: string }) {
- +
-
diff --git a/packages/ark/src/components/note/root.tsx b/packages/ark/src/components/note/root.tsx index 30f0cf77..c7aeb70e 100644 --- a/packages/ark/src/components/note/root.tsx +++ b/packages/ark/src/components/note/root.tsx @@ -10,10 +10,7 @@ export function NoteRoot({ }) { return (
{children} diff --git a/packages/icons/src/navArrowDown.tsx b/packages/icons/src/navArrowDown.tsx index 358ff53e..90248163 100644 --- a/packages/icons/src/navArrowDown.tsx +++ b/packages/icons/src/navArrowDown.tsx @@ -1,15 +1,21 @@ -import { SVGProps } from 'react'; +import { SVGProps } from "react"; export function NavArrowDownIcon( - props: JSX.IntrinsicAttributes & SVGProps + props: JSX.IntrinsicAttributes & SVGProps, ) { - return ( - - Nav Arrow Down - - - ); + return ( + + + + ); } diff --git a/packages/lume-column-thread/src/home.tsx b/packages/lume-column-thread/src/home.tsx index cb2dd4f1..0de9041f 100644 --- a/packages/lume-column-thread/src/home.tsx +++ b/packages/lume-column-thread/src/home.tsx @@ -8,7 +8,7 @@ export function HomeRoute({ id }: { id: string }) {
- +
diff --git a/packages/ui/src/editor/form.tsx b/packages/ui/src/editor/form.tsx index cc51c2ac..5233e9d7 100644 --- a/packages/ui/src/editor/form.tsx +++ b/packages/ui/src/editor/form.tsx @@ -1,4 +1,4 @@ -import { MentionNote, useArk, useColumnContext } from "@lume/ark"; +import { MentionNote, User, useArk, useColumnContext } from "@lume/ark"; import { LoaderIcon, TrashIcon } from "@lume/icons"; import { useStorage } from "@lume/storage"; import { NDKCacheUserProfile } from "@lume/types"; @@ -24,7 +24,6 @@ import { withReact, } from "slate-react"; import { toast } from "sonner"; -import { User } from "../user"; import { EditorAddMedia } from "./addMedia"; import { Portal, @@ -372,7 +371,14 @@ export function EditorForm() { }} className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900" > - + + + +
+ +
+
+
))}
diff --git a/packages/ui/src/editor/replyForm.tsx b/packages/ui/src/editor/replyForm.tsx new file mode 100644 index 00000000..0878d3fc --- /dev/null +++ b/packages/ui/src/editor/replyForm.tsx @@ -0,0 +1,391 @@ +import { MentionNote, User, useArk } from "@lume/ark"; +import { LoaderIcon, TrashIcon } from "@lume/icons"; +import { useStorage } from "@lume/storage"; +import { NDKCacheUserProfile } from "@lume/types"; +import { cn, editorValueAtom } from "@lume/utils"; +import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; +import { Portal } from "@radix-ui/react-dropdown-menu"; +import { useAtom } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { + Descendant, + Editor, + Node, + Range, + Transforms, + createEditor, +} from "slate"; +import { + Editable, + ReactEditor, + Slate, + useFocused, + useSelected, + useSlateStatic, + withReact, +} from "slate-react"; +import { toast } from "sonner"; +import { EditorAddMedia } from "./addMedia"; +import { + insertImage, + insertMention, + insertNostrEvent, + isImageUrl, +} from "./utils"; + +const withNostrEvent = (editor: ReactEditor) => { + const { insertData, isVoid } = editor; + + editor.isVoid = (element) => { + // @ts-expect-error, wtf + return element.type === "event" ? true : isVoid(element); + }; + + editor.insertData = (data) => { + const text = data.getData("text/plain"); + + if (text.startsWith("nevent1") || text.startsWith("note1")) { + insertNostrEvent(editor, text); + } else { + insertData(data); + } + }; + + return editor; +}; + +const withMentions = (editor: ReactEditor) => { + const { isInline, isVoid, markableVoid } = editor; + + editor.isInline = (element) => { + // @ts-expect-error, wtf + return element.type === "mention" ? true : isInline(element); + }; + + editor.isVoid = (element) => { + // @ts-expect-error, wtf + return element.type === "mention" ? true : isVoid(element); + }; + + editor.markableVoid = (element) => { + // @ts-expect-error, wtf + return element.type === "mention" || markableVoid(element); + }; + + return editor; +}; + +const withImages = (editor: ReactEditor) => { + const { insertData, isVoid } = editor; + + editor.isVoid = (element) => { + // @ts-expect-error, wtf + return element.type === "image" ? true : isVoid(element); + }; + + editor.insertData = (data) => { + const text = data.getData("text/plain"); + + if (isImageUrl(text)) { + insertImage(editor, text); + } else { + insertData(data); + } + }; + + return editor; +}; + +const Image = ({ attributes, children, element }) => { + const editor = useSlateStatic(); + const path = ReactEditor.findPath(editor as ReactEditor, element); + + const selected = useSelected(); + const focused = useFocused(); + + return ( +
+ {children} +
+ {element.url} + +
+
+ ); +}; + +const Mention = ({ attributes, element }) => { + const editor = useSlateStatic(); + const path = ReactEditor.findPath(editor as ReactEditor, element); + + return ( + Transforms.removeNodes(editor, { at: path })} + className="inline-block text-blue-500 align-baseline hover:text-blue-600" + >{`@${element.name}`} + ); +}; + +const Event = ({ attributes, element, children }) => { + const editor = useSlateStatic(); + const path = ReactEditor.findPath(editor as ReactEditor, element); + + return ( +
+ {children} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
Transforms.removeNodes(editor, { at: path })} + className="relative user-select-none" + > + +
+
+ ); +}; + +const Element = (props) => { + const { attributes, children, element } = props; + + switch (element.type) { + case "image": + return ; + case "mention": + return ; + case "event": + return ; + default: + return ( +

+ {children} +

+ ); + } +}; + +export function ReplyForm({ + eventId, + className, +}: { eventId: string; className?: string }) { + const ark = useArk(); + const storage = useStorage(); + const ref = useRef(); + + const [editorValue, setEditorValue] = useAtom(editorValueAtom); + const [contacts, setContacts] = useState([]); + const [target, setTarget] = useState(); + const [index, setIndex] = useState(0); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [editor] = useState(() => + withMentions(withNostrEvent(withImages(withReact(createEditor())))), + ); + + const filters = contacts + ?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase())) + ?.slice(0, 10); + + const reset = () => { + // @ts-expect-error, backlog + editor.children = [{ type: "paragraph", children: [{ text: "" }] }]; + setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]); + }; + + const serialize = (nodes: Descendant[]) => { + return nodes + .map((n) => { + // @ts-expect-error, backlog + if (n.type === "image") return n.url; + // @ts-expect-error, backlog + if (n.type === "event") return n.eventId; + + // @ts-expect-error, backlog + if (n.children.length) { + // @ts-expect-error, backlog + return n.children + .map((n) => { + if (n.type === "mention") return n.npub; + return Node.string(n).trim(); + }) + .join(" "); + } + + return Node.string(n); + }) + .join("\n"); + }; + + const submit = async () => { + try { + setLoading(true); + + const event = new NDKEvent(ark.ndk); + event.kind = NDKKind.Text; + event.content = serialize(editor.children); + + const rootEvent = await ark.getEventById(eventId); + event.tag(rootEvent, "root"); + + const publish = await event.publish(); + + if (publish) { + toast.success( + `Event has been published successfully to ${publish.size} relays.`, + ); + + setLoading(false); + + return reset(); + } + } catch (e) { + setLoading(false); + toast.error(String(e)); + } + }; + + useEffect(() => { + async function loadContacts() { + const res = await storage.getAllCacheUsers(); + if (res) setContacts(res); + } + + loadContacts(); + }, []); + + useEffect(() => { + if (target && filters.length > 0) { + const el = ref.current; + const domRange = ReactEditor.toDOMRange(editor, target); + const rect = domRange.getBoundingClientRect(); + el.style.top = `${rect.top + window.pageYOffset + 24}px`; + el.style.left = `${rect.left + window.pageXOffset}px`; + } + }, [filters.length, editor, index, search, target]); + + return ( +
+ + + + + +
+ { + const { selection } = editor; + + if (selection && Range.isCollapsed(selection)) { + const [start] = Range.edges(selection); + const wordBefore = Editor.before(editor, start, { unit: "word" }); + const before = wordBefore && Editor.before(editor, wordBefore); + const beforeRange = before && Editor.range(editor, before, start); + const beforeText = + beforeRange && Editor.string(editor, beforeRange); + const beforeMatch = beforeText?.match(/^@(\w+)$/); + const after = Editor.after(editor, start); + const afterRange = Editor.range(editor, start, after); + const afterText = Editor.string(editor, afterRange); + const afterMatch = afterText.match(/^(\s|$)/); + + if (beforeMatch && afterMatch) { + setTarget(beforeRange); + setSearch(beforeMatch[1]); + setIndex(0); + return; + } + } + + setTarget(null); + }} + > +
+ } + placeholder="Post your reply" + className="focus:outline-none h-28" + /> + {target && filters.length > 0 && ( + +
+ {filters.map((contact, i) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: +
{ + Transforms.select(editor, target); + insertMention(editor, contact); + setTarget(null); + }} + className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900" + > + + + +
+ +
+
+
+
+ ))} +
+
+ )} +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/packages/ui/src/replyList.tsx b/packages/ui/src/replyList.tsx index e03bd925..e39e1fea 100644 --- a/packages/ui/src/replyList.tsx +++ b/packages/ui/src/replyList.tsx @@ -1,38 +1,46 @@ import { Reply, useArk } from "@lume/ark"; import { LoaderIcon } from "@lume/icons"; import { NDKEventWithReplies } from "@lume/types"; -import { type NDKSubscription } from "@nostr-dev-kit/ndk"; +import { cn } from "@lume/utils"; +import { NDKKind, type NDKSubscription } from "@nostr-dev-kit/ndk"; import { useEffect, useState } from "react"; import { twMerge } from "tailwind-merge"; +import { ReplyForm } from "./editor/replyForm"; export function ReplyList({ eventId, - title, className, -}: { eventId: string; title?: string; className?: string }) { +}: { eventId: string; className?: string }) { const ark = useArk(); const [data, setData] = useState(null); useEffect(() => { - let sub: NDKSubscription; + let sub: NDKSubscription = undefined; let isCancelled = false; async function fetchRepliesAndSub() { - const events = await ark.getThreads({ id: eventId }); + const id = ark.getCleanEventId(eventId); + const events = await ark.getThreads(id); + if (!isCancelled) { setData(events); } - // subscribe for new replies - sub = ark.subscribe({ - filter: { - "#e": [eventId], - since: Math.floor(Date.now() / 1000), - }, - closeOnEose: false, - cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]), - }); + + if (!sub) { + sub = ark.subscribe({ + filter: { + "#e": [id], + kinds: [NDKKind.Text], + since: Math.floor(Date.now() / 1000), + }, + closeOnEose: false, + cb: (event: NDKEventWithReplies) => + setData((prev) => [event, ...prev]), + }); + } } + // subscribe for new replies fetchRepliesAndSub(); return () => { @@ -42,14 +50,22 @@ export function ReplyList({ }, [eventId]); return ( -
-

{title}

+
+ {!data ? ( -
+
) : data.length === 0 ? ( -
+

👋

diff --git a/packages/ui/src/routes/event.tsx b/packages/ui/src/routes/event.tsx index e3a157d2..3461f1c8 100644 --- a/packages/ui/src/routes/event.tsx +++ b/packages/ui/src/routes/event.tsx @@ -29,7 +29,7 @@ export function EventRoute() {

- +