import { useArk } from "@lume/ark"; import { LoaderIcon, TrashIcon } from "@lume/icons"; import { Portal, cn, insertImage, insertMention, insertNostrEvent, isImageUrl, sendNativeNotification, } from "@lume/utils"; import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MediaButton } from "./-components/media"; import { MentionNote } from "@lume/ui/src/note/mentions/note"; import { Descendant, Editor, Node, Range, Transforms, createEditor, } from "slate"; import { ReactEditor, useSlateStatic, useSelected, useFocused, withReact, Slate, Editable, } from "slate-react"; import { Contact } from "@lume/types"; import { User } from "@lume/ui"; import { nip19 } from "nostr-tools"; import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; import { invoke } from "@tauri-apps/api/core"; type EditorElement = { type: string; children: Descendant[]; eventId?: string; }; const contactQueryOptions = queryOptions({ queryKey: ["contacts"], queryFn: () => invoke("get_contact_metadata"), refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, }); export const Route = createFileRoute("/editor/")({ loader: ({ context }) => context.queryClient.ensureQueryData(contactQueryOptions), component: Screen, pendingComponent: Pending, }); function Screen() { // @ts-ignore, useless const { reply_to, quote } = Route.useSearch(); let initialValue: EditorElement[]; if (quote) { initialValue = [ { type: "paragraph", children: [{ text: "" }], }, { type: "event", eventId: `nostr:${nip19.noteEncode(reply_to)}`, children: [{ text: "" }], }, { type: "paragraph", children: [{ text: "" }], }, ]; } else { initialValue = [ { type: "paragraph", children: [{ text: "" }], }, ]; } const ark = useArk(); const ref = useRef(); const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[]; 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 [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."); return reset(); } // stop loading setLoading(false); } 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 ? (

Reply to:

) : null}
} placeholder={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}

); } };