mirror of
https://github.com/luminous-devs/lume.git
synced 2024-10-01 09:21:07 +00:00
feat: add reply form
This commit is contained in:
parent
a20f5ca15d
commit
0b745cb40e
@ -228,12 +228,8 @@ export class Ark {
|
|||||||
return [...events];
|
return [...events];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getEventById(id: string) {
|
public getCleanEventId(id: string) {
|
||||||
try {
|
let eventId: string = id.replace("nostr:", "").split("'")[0].split(".")[0];
|
||||||
let eventId: string = id
|
|
||||||
.replace("nostr:", "")
|
|
||||||
.split("'")[0]
|
|
||||||
.split(".")[0];
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
eventId.startsWith("nevent1") ||
|
eventId.startsWith("nevent1") ||
|
||||||
@ -241,18 +237,16 @@ export class Ark {
|
|||||||
eventId.startsWith("naddr1")
|
eventId.startsWith("naddr1")
|
||||||
) {
|
) {
|
||||||
const decode = nip19.decode(eventId);
|
const decode = nip19.decode(eventId);
|
||||||
|
|
||||||
if (decode.type === "nevent") eventId = decode.data.id;
|
if (decode.type === "nevent") eventId = decode.data.id;
|
||||||
if (decode.type === "note") eventId = decode.data;
|
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],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEventById(id: string) {
|
||||||
|
try {
|
||||||
|
const eventId = this.getCleanEventId(id);
|
||||||
return await this.ndk.fetchEvent(eventId);
|
return await this.ndk.fetchEvent(eventId);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("event not found");
|
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 fetcher = NostrFetcher.withCustomPool(ndkAdapter(this.ndk));
|
||||||
|
|
||||||
try {
|
|
||||||
const relayUrls = [...this.ndk.pool.relays.values()].map(
|
const relayUrls = [...this.ndk.pool.relays.values()].map(
|
||||||
(item) => item.url,
|
(item) => item.url,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
const rawEvents = (await fetcher.fetchAllEvents(
|
const rawEvents = (await fetcher.fetchAllEvents(
|
||||||
relayUrls,
|
relayUrls,
|
||||||
{
|
{
|
||||||
kinds: [NDKKind.Text],
|
kinds: [NDKKind.Text],
|
||||||
"#e": [id],
|
"#e": [eventId],
|
||||||
},
|
},
|
||||||
{ since: 0 },
|
{ since: 0 },
|
||||||
{ sort: true },
|
{ sort: true },
|
||||||
|
@ -48,12 +48,12 @@ export function ImagePreview({ url }: { url: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => downloadImage(e)}
|
onClick={(e) => downloadImage(e)}
|
||||||
className="absolute z-10 items-center justify-center hidden w-10 h-10 bg-blue-500 rounded-lg right-2 top-2 group-hover:inline-flex hover:bg-blue-600"
|
className="absolute z-10 items-center justify-center hidden size-8 bg-white/10 text-white backdrop-blur-xl rounded-lg right-2 top-2 group-hover:inline-flex hover:bg-blue-500"
|
||||||
>
|
>
|
||||||
{downloaded ? (
|
{downloaded ? (
|
||||||
<CheckCircleIcon className="w-5 h-5 text-white" />
|
<CheckCircleIcon className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<DownloadIcon className="w-5 h-5 text-white" />
|
<DownloadIcon className="size-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,15 +6,13 @@ export function ChildReply({
|
|||||||
}: { event: NDKEvent; rootEventId?: string }) {
|
}: { event: NDKEvent; rootEventId?: string }) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root className="gap-2 pl-4 mb-5">
|
<Note.Root className="pl-6">
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
<div className="flex items-center justify-between h-14">
|
||||||
<Note.User className="flex-1 pr-1" />
|
<Note.User className="flex-1" />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="min-w-0" />
|
<Note.Content />
|
||||||
<div className="flex items-center gap-10 -ml-1">
|
<div className="flex items-center justify-end gap-10 mt-4">
|
||||||
<Note.Reply />
|
|
||||||
<Note.Reaction />
|
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { NavArrowDownIcon } from "@lume/icons";
|
import { NavArrowDownIcon } from "@lume/icons";
|
||||||
import { NDKEventWithReplies } from "@lume/types";
|
import { NDKEventWithReplies } from "@lume/types";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import { Note } from "..";
|
import { Note } from "..";
|
||||||
import { ChildReply } from "./childReply";
|
import { ChildReply } from "./childReply";
|
||||||
|
|
||||||
@ -16,35 +16,33 @@ export function Reply({
|
|||||||
return (
|
return (
|
||||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root>
|
<Note.Root className="pt-2">
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
<div className="flex items-center justify-between h-14">
|
||||||
<Note.User className="flex-1 pr-1" />
|
<Note.User className="flex-1 pr-1" />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="min-w-0 px-3" />
|
<Note.Content />
|
||||||
<div className="flex items-center justify-between px-3 -ml-1 h-14">
|
<div className="flex items-center justify-between h-14">
|
||||||
{event.replies?.length > 0 ? (
|
{event.replies?.length > 0 ? (
|
||||||
<Collapsible.Trigger asChild>
|
<Collapsible.Trigger asChild>
|
||||||
<div className="inline-flex items-center gap-1 ml-1 font-semibold text-blue-500 h-14">
|
<div className="inline-flex items-center gap-1 font-semibold text-sm text-neutral-600 dark:text-neutral-400 h-14">
|
||||||
<NavArrowDownIcon
|
<NavArrowDownIcon
|
||||||
className={twMerge(
|
className={cn("size-5", open ? "rotate-180 transform" : "")}
|
||||||
"h-3 w-3",
|
|
||||||
open ? "rotate-180 transform" : "",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{`${event.replies?.length} ${
|
{`${event.replies?.length} ${
|
||||||
event.replies?.length === 1 ? "reply" : "replies"
|
event.replies?.length === 1 ? "reply" : "replies"
|
||||||
}`}
|
}`}
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
) : null}
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
<div className="inline-flex items-center gap-10">
|
<div className="inline-flex items-center gap-10">
|
||||||
<Note.Reply />
|
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={twMerge("px-3", open ? "pb-3" : "")}>
|
<div className={cn("", open ? "pb-3" : "")}>
|
||||||
{event.replies?.length > 0 ? (
|
{event.replies?.length > 0 ? (
|
||||||
<Collapsible.Content>
|
<Collapsible.Content>
|
||||||
{event.replies?.map((childEvent) => (
|
{event.replies?.map((childEvent) => (
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { RepostIcon } from "@lume/icons";
|
import { RepostIcon } from "@lume/icons";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
import { NDKEvent, NostrEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent, NostrEvent } from "@nostr-dev-kit/ndk";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Note } from "..";
|
import { Note } from "..";
|
||||||
@ -69,7 +70,12 @@ export function RepostNote({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Root className={className}>
|
<Note.Root
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
<User.Root className="flex gap-2 px-3 h-14">
|
<User.Root className="flex gap-2 px-3 h-14">
|
||||||
<div className="inline-flex shrink-0 w-10 items-center justify-center">
|
<div className="inline-flex shrink-0 w-10 items-center justify-center">
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
import { Note } from "..";
|
import { Note } from "..";
|
||||||
|
|
||||||
@ -7,7 +8,12 @@ export function TextNote({
|
|||||||
}: { event: NDKEvent; className?: string }) {
|
}: { event: NDKEvent; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<Note.Root className={className}>
|
<Note.Root
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.User className="flex-1 pr-1" />
|
<Note.User className="flex-1 pr-1" />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
|
@ -11,7 +11,7 @@ export function ThreadNote({ eventId }: { eventId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={data}>
|
<Note.Provider event={data}>
|
||||||
<Note.Root>
|
<Note.Root className="flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
|
||||||
<div className="flex items-center justify-between px-3 h-16">
|
<div className="flex items-center justify-between px-3 h-16">
|
||||||
<User.Provider pubkey={data.pubkey}>
|
<User.Provider pubkey={data.pubkey}>
|
||||||
<User.Root className="flex h-16 items-center gap-3 flex-1">
|
<User.Root className="flex h-16 items-center gap-3 flex-1">
|
||||||
@ -29,11 +29,10 @@ export function ThreadNote({ eventId }: { eventId: string }) {
|
|||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Thread className="mb-2" />
|
<Note.Thread className="mb-2" />
|
||||||
<Note.Content className="min-w-0 px-3" isTranslatable />
|
<Note.Content className="min-w-0 px-3" />
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
<Note.Pin />
|
<Note.Pin />
|
||||||
<div className="inline-flex items-center gap-10">
|
<div className="inline-flex items-center gap-10">
|
||||||
<Note.Reply />
|
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
<Note.Zap />
|
<Note.Zap />
|
||||||
</div>
|
</div>
|
||||||
|
@ -10,10 +10,7 @@ export function NoteRoot({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("h-min w-full overflow-hidden", className)}
|
||||||
"flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 dark:bg-neutral-950",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
contentEditable={false}
|
contentEditable={false}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,14 +1,20 @@
|
|||||||
import { SVGProps } from 'react';
|
import { SVGProps } from "react";
|
||||||
|
|
||||||
export function NavArrowDownIcon(
|
export function NavArrowDownIcon(
|
||||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
<svg
|
||||||
<title id="navArrowDown">Nav Arrow Down</title>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
d="M4.29233 7.97419C3.37989 6.14866 4.70668 4 6.74799 4H17.2519C19.2932 4 20.62 6.14866 19.7076 7.97419L14.4556 18.4819C13.4439 20.5061 10.556 20.506 9.54431 18.4819L4.29233 7.97419Z"
|
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
d="M12.925 14.673a21.353 21.353 0 003.88-4.08 1 1 0 00-.881-1.59 51.714 51.714 0 01-7.848 0 1 1 0 00-.881 1.59 21.354 21.354 0 003.88 4.08 1.472 1.472 0 001.85 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
@ -8,7 +8,7 @@ export function HomeRoute({ id }: { id: string }) {
|
|||||||
<WindowVirtualizer>
|
<WindowVirtualizer>
|
||||||
<div className="px-3 mt-3">
|
<div className="px-3 mt-3">
|
||||||
<ThreadNote eventId={id} />
|
<ThreadNote eventId={id} />
|
||||||
<ReplyList eventId={id} title="All replies" className="mt-5" />
|
<ReplyList eventId={id} className="mt-5" />
|
||||||
</div>
|
</div>
|
||||||
</WindowVirtualizer>
|
</WindowVirtualizer>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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 { LoaderIcon, TrashIcon } from "@lume/icons";
|
||||||
import { useStorage } from "@lume/storage";
|
import { useStorage } from "@lume/storage";
|
||||||
import { NDKCacheUserProfile } from "@lume/types";
|
import { NDKCacheUserProfile } from "@lume/types";
|
||||||
@ -24,7 +24,6 @@ import {
|
|||||||
withReact,
|
withReact,
|
||||||
} from "slate-react";
|
} from "slate-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { User } from "../user";
|
|
||||||
import { EditorAddMedia } from "./addMedia";
|
import { EditorAddMedia } from "./addMedia";
|
||||||
import {
|
import {
|
||||||
Portal,
|
Portal,
|
||||||
@ -372,7 +371,14 @@ export function EditorForm() {
|
|||||||
}}
|
}}
|
||||||
className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||||
>
|
>
|
||||||
<User pubkey={contact.npub} variant="simple" />
|
<User.Provider pubkey={contact.npub}>
|
||||||
|
<User.Root className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="size-10 rounded-lg object-cover shrink-0" />
|
||||||
|
<div className="flex w-full flex-col items-start">
|
||||||
|
<User.Name className="max-w-[15rem] truncate font-semibold" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
391
packages/ui/src/editor/replyForm.tsx
Normal file
391
packages/ui/src/editor/replyForm.tsx
Normal file
@ -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 (
|
||||||
|
<div {...attributes}>
|
||||||
|
{children}
|
||||||
|
<div contentEditable={false} className="relative">
|
||||||
|
<img
|
||||||
|
src={element.url}
|
||||||
|
alt={element.url}
|
||||||
|
className={cn(
|
||||||
|
"object-cover w-full h-auto border rounded-lg border-neutral-100 dark:border-neutral-900 ring-2",
|
||||||
|
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||||
|
)}
|
||||||
|
contentEditable={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
|
className="absolute inline-flex items-center justify-center text-white bg-red-500 rounded-lg top-2 right-2 size-8 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Mention = ({ attributes, element }) => {
|
||||||
|
const editor = useSlateStatic();
|
||||||
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...attributes}
|
||||||
|
type="button"
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
|
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||||
|
>{`@${element.name}`}</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Event = ({ attributes, element, children }) => {
|
||||||
|
const editor = useSlateStatic();
|
||||||
|
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...attributes}>
|
||||||
|
{children}
|
||||||
|
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||||
|
<div
|
||||||
|
contentEditable={false}
|
||||||
|
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||||
|
className="relative user-select-none"
|
||||||
|
>
|
||||||
|
<MentionNote
|
||||||
|
eventId={element.eventId.replace("nostr:", "")}
|
||||||
|
openable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Element = (props) => {
|
||||||
|
const { attributes, children, element } = props;
|
||||||
|
|
||||||
|
switch (element.type) {
|
||||||
|
case "image":
|
||||||
|
return <Image {...props} />;
|
||||||
|
case "mention":
|
||||||
|
return <Mention {...props} />;
|
||||||
|
case "event":
|
||||||
|
return <Event {...props} />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<p {...attributes} className="text-lg">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReplyForm({
|
||||||
|
eventId,
|
||||||
|
className,
|
||||||
|
}: { eventId: string; className?: string }) {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const ref = useRef<HTMLDivElement | null>();
|
||||||
|
|
||||||
|
const [editorValue, setEditorValue] = useAtom(editorValueAtom);
|
||||||
|
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
|
||||||
|
const [target, setTarget] = useState<Range | undefined>();
|
||||||
|
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 (
|
||||||
|
<div className={cn("flex gap-3", className)}>
|
||||||
|
<User.Provider pubkey={ark.account.pubkey}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Avatar className="size-9 shrink-0 rounded-lg object-cover" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Slate
|
||||||
|
editor={editor}
|
||||||
|
initialValue={editorValue}
|
||||||
|
onChange={() => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full overflow-y-auto p-3 bg-neutral-100 dark:bg-neutral-900 rounded-xl">
|
||||||
|
<Editable
|
||||||
|
key={JSON.stringify(editorValue)}
|
||||||
|
autoFocus={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="none"
|
||||||
|
spellCheck={false}
|
||||||
|
renderElement={(props) => <Element {...props} />}
|
||||||
|
placeholder="Post your reply"
|
||||||
|
className="focus:outline-none h-28"
|
||||||
|
/>
|
||||||
|
{target && filters.length > 0 && (
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="top-[-9999px] left-[-9999px] absolute z-10 w-[250px] p-1 bg-white border border-neutral-50 dark:border-neutral-900 dark:bg-neutral-950 rounded-lg shadow-lg"
|
||||||
|
>
|
||||||
|
{filters.map((contact, i) => (
|
||||||
|
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||||
|
<div
|
||||||
|
key={contact.npub}
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={contact.npub}>
|
||||||
|
<User.Root className="flex items-center gap-2.5">
|
||||||
|
<User.Avatar className="size-10 rounded-lg object-cover shrink-0" />
|
||||||
|
<div className="flex w-full flex-col items-start">
|
||||||
|
<User.Name className="max-w-[15rem] truncate font-semibold" />
|
||||||
|
</div>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between shrink-0">
|
||||||
|
<div />
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<EditorAddMedia />
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 mx-3 bg-neutral-200 dark:bg-neutral-800" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
className="inline-flex items-center justify-center w-20 pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Post"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Slate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,38 +1,46 @@
|
|||||||
import { Reply, useArk } from "@lume/ark";
|
import { Reply, useArk } from "@lume/ark";
|
||||||
import { LoaderIcon } from "@lume/icons";
|
import { LoaderIcon } from "@lume/icons";
|
||||||
import { NDKEventWithReplies } from "@lume/types";
|
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 { useEffect, useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { ReplyForm } from "./editor/replyForm";
|
||||||
|
|
||||||
export function ReplyList({
|
export function ReplyList({
|
||||||
eventId,
|
eventId,
|
||||||
title,
|
|
||||||
className,
|
className,
|
||||||
}: { eventId: string; title?: string; className?: string }) {
|
}: { eventId: string; className?: string }) {
|
||||||
const ark = useArk();
|
const ark = useArk();
|
||||||
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
|
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let sub: NDKSubscription;
|
let sub: NDKSubscription = undefined;
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
async function fetchRepliesAndSub() {
|
async function fetchRepliesAndSub() {
|
||||||
const events = await ark.getThreads({ id: eventId });
|
const id = ark.getCleanEventId(eventId);
|
||||||
|
const events = await ark.getThreads(id);
|
||||||
|
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
setData(events);
|
setData(events);
|
||||||
}
|
}
|
||||||
// subscribe for new replies
|
|
||||||
|
if (!sub) {
|
||||||
sub = ark.subscribe({
|
sub = ark.subscribe({
|
||||||
filter: {
|
filter: {
|
||||||
"#e": [eventId],
|
"#e": [id],
|
||||||
|
kinds: [NDKKind.Text],
|
||||||
since: Math.floor(Date.now() / 1000),
|
since: Math.floor(Date.now() / 1000),
|
||||||
},
|
},
|
||||||
closeOnEose: false,
|
closeOnEose: false,
|
||||||
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
|
cb: (event: NDKEventWithReplies) =>
|
||||||
|
setData((prev) => [event, ...prev]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe for new replies
|
||||||
fetchRepliesAndSub();
|
fetchRepliesAndSub();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -42,14 +50,22 @@ export function ReplyList({
|
|||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={twMerge("flex flex-col gap-5", className)}>
|
<div
|
||||||
<h3 className="font-semibold">{title}</h3>
|
className={cn(
|
||||||
|
"flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReplyForm
|
||||||
|
eventId={eventId}
|
||||||
|
className="py-4 border-t border-neutral-100 dark:border-neutral-900"
|
||||||
|
/>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
<div className="pt-4 flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : data.length === 0 ? (
|
) : data.length === 0 ? (
|
||||||
<div className="flex w-full items-center justify-center bg-neutral-50 dark:bg-neutral-950 rounded-lg">
|
<div className="pt-4 flex w-full items-center justify-center bg-neutral-50 dark:bg-neutral-950 rounded-lg">
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||||
<h3 className="text-3xl">👋</h3>
|
<h3 className="text-3xl">👋</h3>
|
||||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
|
@ -29,7 +29,7 @@ export function EventRoute() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<ThreadNote eventId={id} />
|
<ThreadNote eventId={id} />
|
||||||
<ReplyList eventId={id} title="All replies" className="mt-5" />
|
<ReplyList eventId={id} />
|
||||||
</div>
|
</div>
|
||||||
</WindowVirtualizer>
|
</WindowVirtualizer>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user