diff --git a/package.json b/package.json index 6ca9241f..bf19cbc2 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,7 @@ "react-hook-form": "^7.53.0", "react-i18next": "^15.0.2", "react-string-replace": "^1.1.1", - "slate": "^0.103.0", - "slate-react": "^0.107.1", + "rich-textarea": "^0.26.3", "use-debounce": "^10.0.3", "virtua": "^0.33.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc95dd9d..ff52ecfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,12 +134,9 @@ importers: react-string-replace: specifier: ^1.1.1 version: 1.1.1 - slate: - specifier: ^0.103.0 - version: 0.103.0 - slate-react: - specifier: ^0.107.1 - version: 0.107.1(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(slate@0.103.0) + rich-textarea: + specifier: ^0.26.3 + version: 0.26.3(react@19.0.0-rc-d025ddd3-20240722) use-debounce: specifier: ^10.0.3 version: 10.0.3(react@19.0.0-rc-d025ddd3-20240722) @@ -725,9 +722,6 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@juggle/resize-observer@3.4.0': - resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} - '@lightninglabs/lnc-core@0.3.1-alpha': resolution: {integrity: sha512-I/hThdItLWJ6RU8Z27ZIXhpBS2JJuD3+TjtaQXX2CabaUYXlcN4sk+Kx8N/zG/fk8qZvjlRWum4vHu4ZX554Fg==} @@ -1405,9 +1399,6 @@ packages: '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - '@types/is-hotkey@0.1.10': - resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==} - '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -1417,9 +1408,6 @@ packages: '@types/istanbul-reports@1.1.2': resolution: {integrity: sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==} - '@types/lodash@4.17.7': - resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==} - '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} @@ -1562,9 +1550,6 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - compute-scroll-into-view@3.1.0: - resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1601,10 +1586,6 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - direction@1.0.4: - resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} - hasBin: true - dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -1762,17 +1743,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-hotkey@0.2.0: - resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-plain-object@5.0.0: - resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} - engines: {node: '>=0.10.0'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2094,6 +2068,11 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rich-textarea@0.26.3: + resolution: {integrity: sha512-3IGAzvM9yyIOQR5GmV/zoofKfo2KCMy5yecPDNstxTDTaEAOcKSlgQcUXZBSO5jg9RvHAEgSNxqsJu/7ktCiPw==} + peerDependencies: + react: '>=16.14.0' + rollup@4.22.0: resolution: {integrity: sha512-W21MUIFPZ4+O2Je/EU+GP3iz7PH4pVPUXSbEZdatQnxo29+3rsUjgrJmzuAZU24z7yRAnFN6ukxeAhZh/c7hzg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2105,9 +2084,6 @@ packages: scheduler@0.25.0-rc-d025ddd3-20240722: resolution: {integrity: sha512-W+CjyTUXoOf/l6b2C9uWAFA696ib1s40vKoLnVQ7o34Cgi9t18mJ7ak4AiVsKBy4pibxZAlmAZJvlKr2ra2p0w==} - scroll-into-view-if-needed@3.1.0: - resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2124,16 +2100,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - slate-react@0.107.1: - resolution: {integrity: sha512-CDIFzeSkTqwOaFHIxRg4MnOsv0Ml8/PoaWiM5zL5hvDYFqVXQUEhMNQqpPEFTWJ5xVLzEv/rd9N3WloiCyEWYQ==} - peerDependencies: - react: '>=18.2.0' - react-dom: '>=18.2.0' - slate: '>=0.99.0' - - slate@0.103.0: - resolution: {integrity: sha512-eCUOVqUpADYMZ59O37QQvUdnFG+8rin0OGQAXNHvHbQeVJ67Bu0spQbcy621vtf8GQUXTEQBlk6OP9atwwob4w==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2204,9 +2170,6 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - tiny-invariant@1.3.1: - resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} - tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2834,8 +2797,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@juggle/resize-observer@3.4.0': {} - '@lightninglabs/lnc-core@0.3.1-alpha': {} '@lightninglabs/lnc-web@0.3.1-alpha': @@ -3470,8 +3431,6 @@ snapshots: '@types/estree@1.0.5': {} - '@types/is-hotkey@0.1.10': {} - '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -3483,8 +3442,6 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-lib-report': 3.0.3 - '@types/lodash@4.17.7': {} - '@types/prop-types@15.7.13': {} '@types/react@18.3.8': @@ -3639,8 +3596,6 @@ snapshots: commander@4.1.1: {} - compute-scroll-into-view@3.1.0: {} - convert-source-map@2.0.0: {} cross-spawn@7.0.3: @@ -3665,8 +3620,6 @@ snapshots: didyoumean@1.2.2: {} - direction@1.0.4: {} - dlv@1.1.3: {} eastasianwidth@0.2.0: {} @@ -3829,7 +3782,8 @@ snapshots: dependencies: '@babel/runtime': 7.25.6 - immer@10.1.1: {} + immer@10.1.1: + optional: true invariant@2.2.4: dependencies: @@ -3851,12 +3805,8 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-hotkey@0.2.0: {} - is-number@7.0.0: {} - is-plain-object@5.0.0: {} - isexe@2.0.0: {} jackspeak@3.4.3: @@ -4118,6 +4068,10 @@ snapshots: reusify@1.0.4: {} + rich-textarea@0.26.3(react@19.0.0-rc-d025ddd3-20240722): + dependencies: + react: 19.0.0-rc-d025ddd3-20240722 + rollup@4.22.0: dependencies: '@types/estree': 1.0.5 @@ -4146,10 +4100,6 @@ snapshots: scheduler@0.25.0-rc-d025ddd3-20240722: {} - scroll-into-view-if-needed@3.1.0: - dependencies: - compute-scroll-into-view: 3.1.0 - semver@6.3.1: {} shebang-command@2.0.0: @@ -4160,27 +4110,6 @@ snapshots: signal-exit@4.1.0: {} - slate-react@0.107.1(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(slate@0.103.0): - dependencies: - '@juggle/resize-observer': 3.4.0 - '@types/is-hotkey': 0.1.10 - '@types/lodash': 4.17.7 - direction: 1.0.4 - is-hotkey: 0.2.0 - is-plain-object: 5.0.0 - lodash: 4.17.21 - react: 19.0.0-rc-d025ddd3-20240722 - react-dom: 19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722) - scroll-into-view-if-needed: 3.1.0 - slate: 0.103.0 - tiny-invariant: 1.3.1 - - slate@0.103.0: - dependencies: - immer: 10.1.1 - is-plain-object: 5.0.0 - tiny-warning: 1.0.3 - source-map-js@1.2.1: {} source-map@0.5.7: {} @@ -4272,8 +4201,6 @@ snapshots: dependencies: any-promise: 1.3.0 - tiny-invariant@1.3.1: {} - tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} diff --git a/src-tauri/src/commands/metadata.rs b/src-tauri/src/commands/metadata.rs index 947943ff..8740a840 100644 --- a/src-tauri/src/commands/metadata.rs +++ b/src-tauri/src/commands/metadata.rs @@ -20,6 +20,14 @@ pub struct Profile { website: Option, } +#[derive(Clone, Serialize, Deserialize, Type)] +pub struct Mention { + pubkey: String, + avatar: String, + display_name: String, + name: String, +} + #[tauri::command] #[specta::specta] pub async fn get_profile(id: Option, state: State<'_, Nostr>) -> Result { @@ -195,6 +203,36 @@ pub async fn toggle_contact( } } +#[tauri::command] +#[specta::specta] +pub async fn get_mention_list(state: State<'_, Nostr>) -> Result, String> { + let client = &state.client; + let filter = Filter::new().kind(Kind::Metadata); + + let events = client + .database() + .query(vec![filter]) + .await + .map_err(|e| e.to_string())?; + + let data: Vec = events + .iter() + .map(|event| { + let pubkey = event.pubkey.to_bech32().unwrap(); + let metadata = Metadata::from_json(&event.content).unwrap_or(Metadata::new()); + + Mention { + pubkey, + avatar: metadata.picture.unwrap_or_else(|| "".to_string()), + display_name: metadata.display_name.unwrap_or_else(|| "".to_string()), + name: metadata.name.unwrap_or_else(|| "".to_string()), + } + }) + .collect(); + + Ok(data) +} + #[tauri::command] #[specta::specta] pub async fn set_lume_store( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9dd02192..ed9133dc 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -115,6 +115,7 @@ fn main() { is_contact_list_empty, check_contact, toggle_contact, + get_mention_list, get_lume_store, set_lume_store, set_wallet, diff --git a/src/commands.gen.ts b/src/commands.gen.ts index f75eae8b..810cb787 100644 --- a/src/commands.gen.ts +++ b/src/commands.gen.ts @@ -160,6 +160,14 @@ async toggleContact(id: string, alias: string | null) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_mention_list") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async getLumeStore(key: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_lume_store", { key }) }; @@ -466,6 +474,7 @@ subscription: "subscription" /** user-defined types **/ export type Column = { label: string; url: string; x: number; y: number; width: number; height: number } +export type Mention = { pubkey: string; avatar: string; display_name: string; name: string } export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] } export type NewSettings = Settings export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null } diff --git a/src/commons.ts b/src/commons.ts index 8b8abdbc..61e2df16 100644 --- a/src/commons.ts +++ b/src/commons.ts @@ -15,8 +15,6 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import updateLocale from "dayjs/plugin/updateLocale"; import { decode } from "light-bolt11-decoder"; -import { type BaseEditor, Transforms } from "slate"; -import { ReactEditor } from "slate-react"; import { twMerge } from "tailwind-merge"; import type { RichEvent, Settings } from "./commands.gen"; import { LumeEvent } from "./system"; @@ -59,51 +57,6 @@ export const isImageUrl = (url: string) => { } }; -export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => { - const text = { text: "" }; - const image = [ - { - type: "image", - url, - children: [text], - }, - ]; - const extraText = [ - { - type: "paragraph", - children: [text], - }, - ]; - - // @ts-ignore, idk - ReactEditor.focus(editor); - Transforms.insertNodes(editor, image); - Transforms.insertNodes(editor, extraText); -}; - -export const insertNostrEvent = ( - editor: ReactEditor | BaseEditor, - eventId: string, -) => { - const text = { text: "" }; - const event = [ - { - type: "event", - eventId: `nostr:${eventId}`, - children: [text], - }, - ]; - const extraText = [ - { - type: "paragraph", - children: [text], - }, - ]; - - Transforms.insertNodes(editor, event); - Transforms.insertNodes(editor, extraText); -}; - export function formatCreatedAt(time: number, message = false) { let formated: string; @@ -257,18 +210,16 @@ export async function upload(filePath?: string) { ]; const selected = - filePath || - ( - await open({ - multiple: false, - filters: [ - { - name: "Media", - extensions: allowExts, - }, - ], - }) - ).path; + filePath ?? + (await open({ + multiple: false, + filters: [ + { + name: "Media", + extensions: allowExts, + }, + ], + })); // User cancelled action if (!selected) return null; @@ -331,6 +282,7 @@ export const appSettings = new Store({ image_resize_service: "https://wsrv.nl", use_relay_hint: true, content_warning: true, + trusted_only: true, display_avatar: true, display_zap_button: true, display_repost_button: true, diff --git a/src/routes/editor/-components/media.tsx b/src/routes/editor/-components/media.tsx index e55f460f..29a171f3 100644 --- a/src/routes/editor/-components/media.tsx +++ b/src/routes/editor/-components/media.tsx @@ -1,20 +1,31 @@ -import { insertImage, isImagePath, upload } from "@/commons"; +import { isImagePath, upload } from "@/commons"; import { Spinner } from "@/components"; import { Images } from "@phosphor-icons/react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { message } from "@tauri-apps/plugin-dialog"; -import { useEffect, useTransition } from "react"; -import { useSlateStatic } from "slate-react"; +import { + type Dispatch, + type SetStateAction, + useEffect, + useTransition, +} from "react"; -export function MediaButton() { - const editor = useSlateStatic(); +export function MediaButton({ + setText, + setAttaches, +}: { + setText: Dispatch>; + setAttaches: Dispatch>; +}) { const [isPending, startTransition] = useTransition(); const uploadMedia = () => { startTransition(async () => { try { const image = await upload(); - return insertImage(editor, image); + setText((prev) => `${prev}\n${image}`); + setAttaches((prev) => [...prev, image]); + return; } catch (e) { await message(String(e), { title: "Upload", kind: "error" }); return; @@ -32,7 +43,8 @@ export function MediaButton() { for (const item of items) { if (isImagePath(item)) { const image = await upload(item); - insertImage(editor, image); + setText((prev) => `${prev}\n${image}`); + setAttaches((prev) => [...prev, image]); } } diff --git a/src/routes/editor/index.tsx b/src/routes/editor/index.tsx index 60f83dff..1abc7848 100644 --- a/src/routes/editor/index.tsx +++ b/src/routes/editor/index.tsx @@ -1,23 +1,20 @@ -import { cn, insertImage, insertNostrEvent, isImageUrl } from "@/commons"; +// @ts-nocheck +import { type Mention, commands } from "@/commands.gen"; +import { cn } from "@/commons"; import { Spinner } from "@/components"; import { Note } from "@/components/note"; -import { MentionNote } from "@/components/note/mentions/note"; import { User } from "@/components/user"; import { LumeEvent, useEvent } from "@/system"; import { Feather } from "@phosphor-icons/react"; import { createFileRoute } from "@tanstack/react-router"; import { nip19 } from "nostr-tools"; -import { useEffect, useState } from "react"; -import { type Descendant, Node, Transforms, createEditor } from "slate"; +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { createPortal } from "react-dom"; import { - Editable, - ReactEditor, - Slate, - useFocused, - useSelected, - useSlateStatic, - withReact, -} from "slate-react"; + RichTextarea, + type RichTextareaHandle, + createRegexRenderer, +} from "rich-textarea"; import { MediaButton } from "./-components/media"; import { PowButton } from "./-components/pow"; import { WarningButton } from "./-components/warning"; @@ -27,11 +24,39 @@ type EditorSearch = { quote: string; }; -type EditorElement = { - type: string; - children: Descendant[]; - eventId?: string; -}; +const MENTION_REG = /\B@([\-+\w]*)$/; +const MAX_LIST_LENGTH = 5; + +const renderer = createRegexRenderer([ + [ + /https?:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+/g, + ({ children, key, value }) => ( + + {children} + + ), + ], + [ + /(?:^|\W)nostr:(\w+)(?!\w)/g, + ({ children, key, value }) => ( + + {children} + + ), + ], +]); export const Route = createFileRoute("/editor/")({ validateSearch: (search: Record): EditorSearch => { @@ -40,201 +65,295 @@ export const Route = createFileRoute("/editor/")({ quote: search.quote, }; }, - beforeLoad: ({ search }) => { - let initialValue: EditorElement[]; + beforeLoad: async ({ search }) => { + let users: Mention[] = []; + let initialValue: string; if (search?.quote?.length) { - const eventId = nip19.noteEncode(search.quote); - initialValue = [ - { - type: "paragraph", - children: [{ text: "" }], - }, - { - type: "event", - eventId: `nostr:${eventId}`, - children: [{ text: "" }], - }, - ]; + initialValue = `\nnostr:${nip19.noteEncode(search.quote)}`; } else { - initialValue = [ - { - type: "paragraph", - children: [{ text: "" }], - }, - ]; + initialValue = ""; } - return { initialValue }; + const res = await commands.getMentionList(); + + if (res.status === "ok") { + users = res.data; + } + + return { users, initialValue }; }, component: Screen, }); function Screen() { const { reply_to } = Route.useSearch(); - const { initialValue } = Route.useRouteContext(); + const { users, initialValue } = Route.useRouteContext(); - const [editorValue, setEditorValue] = useState(null); - const [loading, setLoading] = useState(false); + const [isPending, startTransition] = useTransition(); + const [text, setText] = useState(""); + const [attaches, setAttaches] = useState(null); const [warning, setWarning] = useState({ enable: false, reason: "" }); const [difficulty, setDifficulty] = useState({ enable: false, num: 21 }); - const [editor] = useState(() => - withMentions(withNostrEvent(withImages(withReact(createEditor())))), + const [index, setIndex] = useState(0); + const [pos, setPos] = useState<{ + top: number; + left: number; + caret: number; + } | null>(null); + + const ref = useRef(null); + const targetText = pos ? text.slice(0, pos.caret) : text; + const match = pos && targetText.match(MENTION_REG); + const name = match?.[1] ?? ""; + const filtered = useMemo( + () => + users + .filter((u) => u.name.toLowerCase().startsWith(name.toLowerCase())) + .slice(0, MAX_LIST_LENGTH), + [name], ); - const reset = () => { - // @ts-expect-error, backlog - editor.children = [{ type: "paragraph", children: [{ text: "" }] }]; - setEditorValue([{ type: "paragraph", children: [{ text: "" }] }]); - }; + const insert = (i: number) => { + if (!ref.current || !pos) return; - 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; + const selected = filtered[i]; - // @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(" "); - } + ref.current.setRangeText( + `nostr:${selected.pubkey} `, + pos.caret - name.length - 1, + pos.caret, + "end", + ); - return Node.string(n); - }) - .join("\n"); + setPos(null); + setIndex(0); }; const publish = async () => { - try { - // start loading - setLoading(true); + startTransition(async () => { + try { + const content = text.trim(); - const content = serialize(editor.children); - const eventId = await LumeEvent.publish( - content, - warning.enable && warning.reason.length ? warning.reason : null, - difficulty.enable && difficulty.num > 0 ? difficulty.num : null, - reply_to, - ); + await LumeEvent.publish( + content, + warning.enable && warning.reason.length ? warning.reason : null, + difficulty.num, + reply_to, + ); - if (eventId) { - // stop loading - setLoading(false); - // reset form - reset(); + setText(""); + } catch { + return; } - } catch (e) { - setLoading(false); - } + }); }; useEffect(() => { - setEditorValue(initialValue); + if (initialValue?.length) { + setText(initialValue); + } }, [initialValue]); - if (!editorValue) return null; - return (
- -
-
- {reply_to?.length ? ( -
- Reply to: - -
- ) : null} -
- } - placeholder={ - reply_to ? "Type your reply..." : "What're you up to?" - } - className="focus:outline-none" - /> -
-
- {warning.enable ? ( -
- - Reason: - - - setWarning((prev) => ({ ...prev, reason: e.target.value })) - } - className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50" - /> +
+
+ {reply_to?.length ? ( +
+ Reply to: +
) : null} - {difficulty.enable ? ( -
- - Difficulty: - - { - if (!/[0-9]/.test(event.key)) { - event.preventDefault(); +
+ setText(e.target.value)} + onKeyDown={(e) => { + if (!pos || !filtered.length) return; + switch (e.code) { + case "ArrowUp": { + e.preventDefault(); + const nextIndex = + index <= 0 ? filtered.length - 1 : index - 1; + setIndex(nextIndex); + break; } - }} - placeholder="21" - defaultValue={difficulty.num} - onChange={(e) => - setWarning((prev) => ({ ...prev, num: Number(e.target.value) })) + case "ArrowDown": { + e.preventDefault(); + const prevIndex = + index >= filtered.length - 1 ? 0 : index + 1; + setIndex(prevIndex); + break; + } + case "Enter": + e.preventDefault(); + insert(index); + break; + case "Escape": + e.preventDefault(); + setPos(null); + setIndex(0); + break; + default: + break; } - className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50" - /> -
- ) : null} -
- -
- - - -
+ {renderer} + + {pos + ? createPortal( + , + document.body, + ) + : null}
- +
+ {warning.enable ? ( +
+ + Reason: + + + setWarning((prev) => ({ ...prev, reason: e.target.value })) + } + className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50" + /> +
+ ) : null} + {difficulty.enable ? ( +
+ + Difficulty: + + { + if (!/[0-9]/.test(event.key)) { + event.preventDefault(); + } + }} + placeholder="21" + defaultValue={difficulty.num} + onChange={(e) => + setWarning((prev) => ({ ...prev, num: Number(e.target.value) })) + } + className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50" + /> +
+ ) : null} +
+ +
+ + + +
+
); } -function ChildNote({ id }: { id: string }) { +function Menu({ + users, + index, + top, + left, + insert, +}: { + users: Mention[]; + index: number; + top: number; + left: number; + insert: (index: number) => void; +}) { + return ( +
+ {users.map((u, i) => ( +
{ + e.preventDefault(); + insert(i); + }} + > +
+ {u.avatar?.length ? ( + + ) : ( +
+ )} +
+ {u.name} +
+ ))} +
+ ); +} + +function EmbedNote({ id }: { id: string }) { const { isLoading, isError, data } = useEvent(id); if (isLoading) { @@ -258,142 +377,3 @@ function ChildNote({ id }: { id: string }) { ); } - -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("nevent") || text.startsWith("note")) { - 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, element, children }) => { - const editor = useSlateStatic(); - const selected = useSelected(); - const focused = useFocused(); - const path = ReactEditor.findPath(editor as ReactEditor, element); - - return ( -
- {children} - {element.url} Transforms.removeNodes(editor, { at: path })} - onKeyDown={() => Transforms.removeNodes(editor, { at: path })} - /> -
- ); -}; - -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} -
Transforms.removeNodes(editor, { at: path })} - onKeyDown={() => Transforms.removeNodes(editor, { at: path })} - > - -
-
- ); -}; - -const Element = (props) => { - const { attributes, children, element } = props; - - switch (element.type) { - case "image": - return ; - case "mention": - return ; - case "event": - return ; - default: - return ( -

- {children} -

- ); - } -};