import { ComposeFilledIcon, TrashIcon } from "@lume/icons"; import { Spinner, User } from "@lume/ui"; import { MentionNote } from "@lume/ui/src/note/mentions/note"; import { Portal, cn, insertImage, insertMention, insertNostrEvent, isImageUrl, sendNativeNotification, } from "@lume/utils"; import { createFileRoute } from "@tanstack/react-router"; import { nip19 } from "nostr-tools"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { type Descendant, Editor, Node, Range, 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"; 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, pendingComponent: Pending, }); function Screen() { const ref = useRef(); const { reply_to, quote } = Route.useSearch(); const { ark, initialValue, contacts } = Route.useRouteContext(); const [t] = useTranslation(); const [editorValue, setEditorValue] = useState(initialValue); const [target, setTarget] = useState(); const [index, setIndex] = useState(0); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false); const [nsfw, setNsfw] = useState(false); const [editor] = useState(() => withMentions(withNostrEvent(withImages(withReact(createEditor())))), ); const filters = contacts ?.filter((c) => c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()), ) ?.slice(0, 5) ?? []; 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("You've publish new post successfully."); } // stop loading setLoading(false); // reset form reset(); } catch (e) { setLoading(false); await sendNativeNotification(String(e)); } }; 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.scrollY + 24}px`; el.style.left = `${rect.left + window.scrollX}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); }} >
{reply_to && !quote ? : null}
} placeholder={ reply_to ? "Type your reply..." : t("editor.placeholder") } className="focus:outline-none" /> {target && filters.length > 0 && (
{filters.map((contact) => ( ))}
)}
); } function Pending() { return (

Loading cache...

); } 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}

); } };