diff --git a/apps/desktop2/src/routes/auth/create/self.lazy.tsx b/apps/desktop2/src/routes/auth/create/self.lazy.tsx
index 35f2a4a6..12a5ef51 100644
--- a/apps/desktop2/src/routes/auth/create/self.lazy.tsx
+++ b/apps/desktop2/src/routes/auth/create/self.lazy.tsx
@@ -26,7 +26,7 @@ function Create() {
try {
await ark.save_account(keys);
navigate({
- to: "/app/home",
+ to: "/app/home/local",
search: { onboarding: true },
replace: true,
});
diff --git a/apps/desktop2/src/routes/editor/-components/media.tsx b/apps/desktop2/src/routes/editor/-components/media.tsx
new file mode 100644
index 00000000..147f14c3
--- /dev/null
+++ b/apps/desktop2/src/routes/editor/-components/media.tsx
@@ -0,0 +1,78 @@
+import { useArk } from "@lume/ark";
+import { AddMediaIcon, LoaderIcon } from "@lume/icons";
+import { cn, insertImage, isImagePath } from "@lume/utils";
+import { useEffect, useState } from "react";
+import { useSlateStatic } from "slate-react";
+import { toast } from "sonner";
+import { getCurrent } from "@tauri-apps/api/window";
+import { UnlistenFn } from "@tauri-apps/api/event";
+
+export function MediaButton({ className }: { className?: string }) {
+ const ark = useArk();
+ const editor = useSlateStatic();
+
+ const [loading, setLoading] = useState(false);
+
+ const uploadToNostrBuild = async () => {
+ try {
+ setLoading(true);
+
+ const image = await ark.upload();
+
+ if (image) {
+ insertImage(editor, image);
+ }
+
+ setLoading(false);
+ } catch (e) {
+ setLoading(false);
+ toast.error(`Upload failed, error: ${e}`);
+ }
+ };
+
+ useEffect(() => {
+ let unlisten: UnlistenFn = undefined;
+
+ async function listenFileDrop() {
+ const window = getCurrent();
+ if (!unlisten) {
+ unlisten = await window.listen("tauri://file-drop", async (event) => {
+ // @ts-ignore, lfg !!!
+ const items: string[] = event.payload.paths;
+ // start loading
+ setLoading(true);
+ // upload all images
+ for (const item of items) {
+ if (isImagePath(item)) {
+ const image = await ark.upload(item);
+ insertImage(editor, image);
+ }
+ }
+ // stop loading
+ setLoading(false);
+ });
+ }
+ }
+
+ listenFileDrop();
+
+ return () => {
+ if (unlisten) unlisten();
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/apps/desktop2/src/routes/editor/index.lazy.tsx b/apps/desktop2/src/routes/editor/index.lazy.tsx
new file mode 100644
index 00000000..e3e23eed
--- /dev/null
+++ b/apps/desktop2/src/routes/editor/index.lazy.tsx
@@ -0,0 +1,379 @@
+import { useArk } from "@lume/ark";
+import { LoaderIcon, TrashIcon } from "@lume/icons";
+import {
+ Portal,
+ cn,
+ insertImage,
+ insertMention,
+ insertNostrEvent,
+ isImagePath,
+ isImageUrl,
+ sendNativeNotification,
+} from "@lume/utils";
+import { createLazyFileRoute } 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";
+
+export const Route = createLazyFileRoute("/editor/")({
+ component: Screen,
+});
+
+function Screen() {
+ const ark = useArk();
+ const ref = useRef
();
+
+ const [t] = useTranslation();
+ const [editorValue, setEditorValue] = useState([
+ {
+ type: "paragraph",
+ children: [{ text: "" }],
+ },
+ ]);
+ const [contacts, setContacts] = useState([]);
+ 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, 10);
+
+ 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);
+
+ 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);
+ }}
+ >
+
+
+
+
+
+
+
+
}
+ placeholder={t("editor.placeholder")}
+ className="focus:outline-none"
+ />
+ {target && filters.length > 0 && (
+
+
+ {filters.map((contact) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+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}
+
+

+
+
+
+ );
+};
+
+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}
+
+ );
+ }
+};
diff --git a/apps/desktop2/src/routes/events/-components/replyList.tsx b/apps/desktop2/src/routes/events/-components/replyList.tsx
index ded929ab..2ab517e6 100644
--- a/apps/desktop2/src/routes/events/-components/replyList.tsx
+++ b/apps/desktop2/src/routes/events/-components/replyList.tsx
@@ -4,6 +4,7 @@ import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { EventWithReplies } from "@lume/types";
+import { Reply } from "./reply";
export function ReplyList({
eventId,
diff --git a/packages/ark/package.json b/packages/ark/package.json
index 7d6aa06e..80b5f61a 100644
--- a/packages/ark/package.json
+++ b/packages/ark/package.json
@@ -6,7 +6,6 @@
"dependencies": {
"@getalby/sdk": "^3.3.0",
"@lume/icons": "workspace:^",
- "@lume/storage": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
diff --git a/packages/ark/src/ark.ts b/packages/ark/src/ark.ts
index 61d0a24e..81f31dc9 100644
--- a/packages/ark/src/ark.ts
+++ b/packages/ark/src/ark.ts
@@ -7,6 +7,9 @@ import type {
} from "@lume/types";
import { invoke } from "@tauri-apps/api/core";
import { WebviewWindow } from "@tauri-apps/api/webview";
+import { open } from "@tauri-apps/plugin-dialog";
+import { readFile } from "@tauri-apps/plugin-fs";
+import { generateContentTags } from "@lume/utils";
export class Ark {
public account: Account;
@@ -147,17 +150,27 @@ export class Ark {
}
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
- } catch (e) {
+ } catch {
return [];
}
}
public async publish(content: string) {
try {
- const cmd: string = await invoke("publish", { content });
+ const g = await generateContentTags(content);
+
+ const eventContent = g.content;
+ const eventTags = g.tags;
+
+ const cmd: string = await invoke("publish", {
+ content: eventContent,
+ tags: eventTags,
+ });
+
return cmd;
} catch (e) {
console.error(String(e));
+ return false;
}
}
@@ -341,6 +354,61 @@ export class Ark {
}
}
+ public async upload(filePath?: string) {
+ try {
+ const allowExts = [
+ "png",
+ "jpeg",
+ "jpg",
+ "gif",
+ "mp4",
+ "mp3",
+ "webm",
+ "mkv",
+ "avi",
+ "mov",
+ ];
+
+ let selected =
+ filePath ||
+ (
+ await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Media",
+ extensions: allowExts,
+ },
+ ],
+ })
+ ).path;
+
+ if (!selected) return null;
+
+ const file = await readFile(selected);
+ const blob = new Blob([file]);
+
+ const data = new FormData();
+ data.append("fileToUpload", blob);
+ data.append("submit", "Upload Image");
+
+ const res = await fetch("https://nostr.build/api/v2/upload/files", {
+ method: "POST",
+ body: data,
+ });
+
+ if (!res.ok) return null;
+
+ const json = await res.json();
+ const content = json.data[0];
+
+ return content.url as string;
+ } catch (e) {
+ console.error(String(e));
+ return null;
+ }
+ }
+
public open_thread(id: string) {
return new WebviewWindow(`event-${id}`, {
title: "Thread",
@@ -364,4 +432,17 @@ export class Ark {
titleBarStyle: "overlay",
});
}
+
+ public open_editor() {
+ return new WebviewWindow("editor", {
+ title: "Editor",
+ url: "/editor",
+ minWidth: 500,
+ width: 600,
+ height: 400,
+ hiddenTitle: true,
+ titleBarStyle: "overlay",
+ fileDropEnabled: true,
+ });
+ }
}
diff --git a/packages/lume-column-foryou/package.json b/packages/lume-column-foryou/package.json
index e95e13a5..aac225c9 100644
--- a/packages/lume-column-foryou/package.json
+++ b/packages/lume-column-foryou/package.json
@@ -6,7 +6,6 @@
"dependencies": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
- "@lume/storage": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@tanstack/react-query": "^5.20.5",
diff --git a/packages/storage/package.json b/packages/storage/package.json
deleted file mode 100644
index 78e15393..00000000
--- a/packages/storage/package.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "name": "@lume/storage",
- "version": "0.0.0",
- "main": "./src/index.ts",
- "private": true,
- "license": "MIT",
- "publishConfig": {
- "access": "public"
- },
- "dependencies": {
- "@tauri-apps/plugin-store": "2.0.0-beta.0",
- "react": "^18.2.0",
- "scheduler": "^0.23.0",
- "use-context-selector": "^1.4.1",
- "virtua": "^0.27.0",
- "zustand": "^4.5.0"
- },
- "devDependencies": {
- "@lume/tsconfig": "workspace:*",
- "@lume/types": "workspace:*",
- "@lume/utils": "workspace:^",
- "@types/react": "^18.2.55",
- "typescript": "^5.3.3"
- }
-}
diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts
deleted file mode 100644
index 1a8f850f..00000000
--- a/packages/storage/src/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./provider";
diff --git a/packages/storage/src/provider.tsx b/packages/storage/src/provider.tsx
deleted file mode 100644
index 8d598c4f..00000000
--- a/packages/storage/src/provider.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { LumeColumn } from "@lume/types";
-import { locale, platform } from "@tauri-apps/plugin-os";
-import { Store } from "@tauri-apps/plugin-store";
-import {
- MutableRefObject,
- PropsWithChildren,
- useCallback,
- useRef,
- useState,
-} from "react";
-import { createContext, useContextSelector } from "use-context-selector";
-import { type VListHandle } from "virtua";
-import { LumeStorage } from "./storage";
-
-const platformName = await platform();
-const osLocale = (await locale()).slice(0, 2);
-
-const store = new Store("lume.dat");
-const storage = new LumeStorage(store, platformName, osLocale);
-await storage.init();
-
-type StorageContext = {
- storage: LumeStorage;
- column: {
- columns: LumeColumn[];
- vlistRef: MutableRefObject;
- create: (column: LumeColumn) => void;
- remove: (id: number) => void;
- move: (id: number, position: "left" | "right") => void;
- update: (id: number, title: string, content: string) => void;
- };
-};
-
-const StorageContext = createContext(null);
-
-export const StorageProvider = ({ children }: PropsWithChildren