import { ComposeFilledIcon, TrashIcon } from "@lume/icons"; import { Spinner } from "@lume/ui"; import { cn, insertImage, insertNostrEvent, isImageUrl, sendNativeNotification, } from "@lume/utils"; import { createFileRoute } from "@tanstack/react-router"; import { nip19 } from "nostr-tools"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { type Descendant, Node, Transforms, createEditor } from "slate"; import { Editable, ReactEditor, Slate, useFocused, useSelected, useSlateStatic, withReact, } from "slate-react"; import { MediaButton } from "./-components/media"; import { NsfwToggle } from "./-components/nsfw"; import { MentionButton } from "./-components/mention"; import { MentionNote } from "@/components/note/mentions/note"; import { toast } from "sonner"; type EditorSearch = { reply_to: string; quote: boolean; }; export const Route = createFileRoute("/editor/")({ validateSearch: (search: Record): EditorSearch => { return { reply_to: search.reply_to, quote: search.quote === "true" || false, }; }, beforeLoad: async ({ search }) => { return { initialValue: search.quote ? [ { type: "paragraph", children: [{ text: "" }], }, { type: "event", eventId: `nostr:${nip19.noteEncode(search.reply_to)}`, children: [{ text: "" }], }, { type: "paragraph", children: [{ text: "" }], }, ] : [ { type: "paragraph", children: [{ text: "" }], }, ], }; }, component: Screen, }); function Screen() { const { reply_to, quote } = Route.useSearch(); const { ark, initialValue } = Route.useRouteContext(); const [t] = useTranslation(); const [editorValue, setEditorValue] = useState(initialValue); const [loading, setLoading] = useState(false); const [nsfw, setNsfw] = useState(false); const [editor] = useState(() => withMentions(withNostrEvent(withImages(withReact(createEditor())))), ); 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 publish = async () => { try { // start loading setLoading(true); const content = serialize(editor.children); const eventId = await ark.publish(content, reply_to, quote); if (eventId) { await sendNativeNotification( "Your note has been published successfully.", "Lume", ); } // stop loading setLoading(false); // reset form reset(); } catch (e) { setLoading(false); await sendNativeNotification(String(e)); } }; return (
{reply_to && !quote ? (
) : null}
} placeholder={ reply_to ? "Type your reply..." : t("editor.placeholder") } className="focus:outline-none" />
); } 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 align-baseline text-blue-500 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="user-select-none relative my-2" >
); }; const Element = (props) => { const { attributes, children, element } = props; switch (element.type) { case "image": return ; case "mention": return ; case "event": return ; default: return (

{children}

); } };