feat: new post editor

This commit is contained in:
reya 2024-09-26 10:57:58 +07:00
parent bacfaed48a
commit 0a8eed9a46
8 changed files with 382 additions and 464 deletions

View File

@ -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"
},

101
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -20,6 +20,14 @@ pub struct Profile {
website: Option<String>,
}
#[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<String>, state: State<'_, Nostr>) -> Result<String, String> {
@ -195,6 +203,36 @@ pub async fn toggle_contact(
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, 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<Mention> = 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(

View File

@ -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,

View File

@ -160,6 +160,14 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
else return { status: "error", error: e as any };
}
},
async getMentionList() : Promise<Result<Mention[], string>> {
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<Result<string, string>> {
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 }

View File

@ -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<Settings>({
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,

View File

@ -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<SetStateAction<string>>;
setAttaches: Dispatch<SetStateAction<string[]>>;
}) {
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]);
}
}

View File

@ -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 }) => (
<a
key={key}
href={value}
target="_blank"
rel="noreferrer"
className="text-blue-500 !underline"
>
{children}
</a>
),
],
[
/(?:^|\W)nostr:(\w+)(?!\w)/g,
({ children, key, value }) => (
<a
key={key}
href={value}
target="_blank"
rel="noreferrer"
className="text-blue-500"
>
{children}
</a>
),
],
]);
export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): 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<EditorElement[]>(null);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const [text, setText] = useState("");
const [attaches, setAttaches] = useState<string[]>(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<number>(0);
const [pos, setPos] = useState<{
top: number;
left: number;
caret: number;
} | null>(null);
const ref = useRef<RichTextareaHandle>(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 (
<div className="flex flex-col w-full h-full">
<Slate editor={editor} initialValue={editorValue}>
<div data-tauri-drag-region className="h-11 shrink-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{reply_to?.length ? (
<div className="flex flex-col gap-2 px-3.5 pb-3 border-b border-black/5 dark:border-white/5">
<span className="text-sm font-semibold">Reply to:</span>
<ChildNote id={reply_to} />
</div>
) : null}
<div className="px-4 py-4 overflow-y-auto">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
autoCapitalize="none"
autoCorrect="none"
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={
reply_to ? "Type your reply..." : "What're you up to?"
}
className="focus:outline-none"
/>
</div>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
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"
/>
<div data-tauri-drag-region className="h-11 shrink-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{reply_to?.length ? (
<div className="flex flex-col gap-2 px-3.5 pb-3 border-b border-black/5 dark:border-white/5">
<span className="text-sm font-semibold">Reply to:</span>
<EmbedNote id={reply_to} />
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
<div className="p-4 overflow-y-auto h-full">
<RichTextarea
ref={ref}
value={text}
placeholder={reply_to ? "Type your reply..." : "What're you up to?"}
style={{ width: "100%", height: "100%" }}
className="text-[15px] leading-normal resize-none border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 placeholder:pt-[1.5px] placeholder:pl-2"
onChange={(e) => 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"
/>
</div>
) : null}
<div
data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
>
<button
type="button"
onClick={() => publish()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
}}
onSelectionChange={(r) => {
if (
r.focused &&
MENTION_REG.test(text.slice(0, r.selectionStart))
) {
setPos({
top: r.top + r.height,
left: r.left,
caret: r.selectionStart,
});
setIndex(0);
} else {
setPos(null);
setIndex(0);
}
}}
>
{loading ? (
<Spinner className="size-4" />
) : (
<Feather className="size-4" weight="fill" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
{renderer}
</RichTextarea>
{pos
? createPortal(
<Menu
top={pos.top}
left={pos.left}
users={filtered}
index={index}
insert={insert}
/>,
document.body,
)
: null}
</div>
</Slate>
</div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
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"
/>
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
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"
/>
</div>
) : null}
<div
data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
>
<button
type="button"
onClick={() => publish()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{isPending ? (
<Spinner className="size-4" />
) : (
<Feather className="size-4" weight="fill" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton setText={setText} setAttaches={setAttaches} />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
</div>
</div>
);
}
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 (
<div
style={{
top: top,
left: left,
}}
className="fixed w-[200px] text-sm bg-white dark:bg-black shadow-lg shadow-neutral-500/20 rounded-lg overflow-hidden"
>
{users.map((u, i) => (
<div
key={u.pubkey}
className={cn(
"flex items-center gap-1.5 p-2",
index === i ? "bg-neutral-100 dark:bg-neutral-900" : null,
)}
onMouseDown={(e) => {
e.preventDefault();
insert(i);
}}
>
<div className="size-7 shrink-0">
{u.avatar?.length ? (
<img
src={u.avatar}
className="size-7 rounded-full outline outline-1 -outline-offset-1 outline-black/15"
loading="lazy"
decoding="async"
/>
) : (
<div className="size-7 rounded-full bg-blue-500" />
)}
</div>
{u.name}
</div>
))}
</div>
);
}
function EmbedNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
@ -258,142 +377,3 @@ function ChildNote({ id }: { id: string }) {
</Note.Provider>
);
}
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 (
<div {...attributes}>
{children}
<img
src={element.url}
alt={element.url}
className={cn(
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
/>
</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}
<div
contentEditable={false}
className="relative my-2 user-select-none"
onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
>
<MentionNote eventId={element.eventId} />
</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-[15px]">
{children}
</p>
);
}
};