From 98ef1927f22e752ab0a8e9d7f037595ddd82886d Mon Sep 17 00:00:00 2001 From: reya Date: Mon, 26 Feb 2024 15:10:42 +0700 Subject: [PATCH] feat: editor --- apps/desktop2/package.json | 2 + apps/desktop2/src/app.tsx | 31 ++----- .../editor/{index.lazy.tsx => index.tsx} | 93 +++++++++++++++---- apps/desktop2/src/routes/index.tsx | 4 +- packages/ark/src/ark.ts | 61 +++++++++--- packages/types/index.d.ts | 1 + packages/ui/src/note/buttons/reply.tsx | 69 ++++++++++---- packages/ui/src/note/buttons/repost.tsx | 39 ++------ pnpm-lock.yaml | 31 ++++++- src-tauri/src/main.rs | 2 +- src-tauri/src/nostr/event.rs | 19 ---- src-tauri/src/nostr/metadata.rs | 39 +++++++- 12 files changed, 256 insertions(+), 135 deletions(-) rename apps/desktop2/src/routes/editor/{index.lazy.tsx => index.tsx} (80%) diff --git a/apps/desktop2/package.json b/apps/desktop2/package.json index 8fef6346..9ea30b95 100644 --- a/apps/desktop2/package.json +++ b/apps/desktop2/package.json @@ -15,12 +15,14 @@ "@lume/utils": "workspace:^", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", + "@tanstack/query-sync-storage-persister": "^5.24.1", "@tanstack/react-query": "^5.22.2", "@tanstack/react-query-persist-client": "^5.22.2", "@tanstack/react-router": "^1.16.6", "i18next": "^23.10.0", "i18next-resources-to-backend": "^1.2.0", "idb-keyval": "^6.2.1", + "nostr-tools": "^2.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.0.5", diff --git a/apps/desktop2/src/app.tsx b/apps/desktop2/src/app.tsx index ee31f923..07b62724 100644 --- a/apps/desktop2/src/app.tsx +++ b/apps/desktop2/src/app.tsx @@ -1,6 +1,6 @@ import { useArk } from "@lume/ark"; import { ArkProvider } from "./ark"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import React, { StrictMode } from "react"; import ReactDOM from "react-dom/client"; @@ -9,36 +9,23 @@ import "./app.css"; import i18n from "./locale"; import { Toaster } from "sonner"; import { locale, platform } from "@tauri-apps/plugin-os"; -import { routeTree } from "./router.gen"; // auto generated file -import { get, set, del } from "idb-keyval"; -import { - PersistedClient, - Persister, -} from "@tanstack/react-query-persist-client"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; +import { routeTree } from "./router.gen"; // auto generated file -function createIDBPersister(idbValidKey: IDBValidKey = "reactQuery") { - return { - persistClient: async (client: PersistedClient) => { - await set(idbValidKey, client); - }, - restoreClient: async () => { - return await get(idbValidKey); - }, - removeClient: async () => { - await del(idbValidKey); - }, - } as Persister; -} - -const persister = createIDBPersister(); const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24, // 24 hours + staleTime: 1000 * 60 * 5, // 5 minutes }, }, }); + +const persister = createSyncStoragePersister({ + storage: window.localStorage, +}); + const platformName = await platform(); const osLocale = (await locale()).slice(0, 2); diff --git a/apps/desktop2/src/routes/editor/index.lazy.tsx b/apps/desktop2/src/routes/editor/index.tsx similarity index 80% rename from apps/desktop2/src/routes/editor/index.lazy.tsx rename to apps/desktop2/src/routes/editor/index.tsx index e3e23eed..e400168f 100644 --- a/apps/desktop2/src/routes/editor/index.lazy.tsx +++ b/apps/desktop2/src/routes/editor/index.tsx @@ -6,11 +6,10 @@ import { insertImage, insertMention, insertNostrEvent, - isImagePath, isImageUrl, sendNativeNotification, } from "@lume/utils"; -import { createLazyFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MediaButton } from "./-components/media"; @@ -34,23 +33,68 @@ import { } 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"; -export const Route = createLazyFileRoute("/editor/")({ +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([ - { - type: "paragraph", - children: [{ text: "" }], - }, - ]); - const [contacts, setContacts] = useState([]); + const [editorValue, setEditorValue] = useState(initialValue); const [target, setTarget] = useState(); const [index, setIndex] = useState(0); const [search, setSearch] = useState(""); @@ -63,7 +107,7 @@ function Screen() { ?.filter((c) => c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()), ) - ?.slice(0, 10); + ?.slice(0, 5); const reset = () => { // @ts-expect-error, backlog @@ -101,7 +145,7 @@ function Screen() { setLoading(true); const content = serialize(editor.children); - const eventId = await ark.publish(content); + const eventId = await ark.publish(content, reply_to, quote); if (eventId) { await sendNativeNotification("You've publish new post successfully."); @@ -162,7 +206,7 @@ function Screen() { data-tauri-drag-region className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2" > - + ))} -
+
diff --git a/packages/ark/src/ark.ts b/packages/ark/src/ark.ts index 6573a643..70cd79f4 100644 --- a/packages/ark/src/ark.ts +++ b/packages/ark/src/ark.ts @@ -1,6 +1,7 @@ import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import type { Account, + Contact, Event, EventWithReplies, Keys, @@ -12,11 +13,10 @@ import { readFile } from "@tauri-apps/plugin-fs"; import { generateContentTags } from "@lume/utils"; export class Ark { - public account: Account; - public accounts: Array; + public accounts: Account[]; constructor() { - this.account = { npub: "", contacts: [] }; + this.accounts = []; } public async get_all_accounts() { @@ -43,12 +43,6 @@ export class Ark { npub: fullNpub, }); - if (cmd) { - const contacts: string[] = await invoke("get_contact_list"); - this.account.npub = npub; - this.account.contacts = contacts; - } - return cmd; } catch (e) { console.error(e); @@ -71,9 +65,6 @@ export class Ark { if (cmd) { await invoke("update_signer", { nsec: keys.nsec }); - const contacts: string[] = await invoke("get_contact_list"); - this.account.npub = keys.npub; - this.account.contacts = contacts; } return cmd; @@ -155,13 +146,35 @@ export class Ark { } } - public async publish(content: string) { + public async publish(content: string, reply_to?: string, quote?: boolean) { try { const g = await generateContentTags(content); const eventContent = g.content; const eventTags = g.tags; + if (reply_to) { + const replyEvent = await this.get_event(reply_to); + + if (quote) { + eventTags.push([ + "e", + replyEvent.id, + replyEvent.relay || "", + "mention", + ]); + } else { + const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root"); + + if (rootEvent) { + eventTags.push(["e", rootEvent[1], rootEvent[2] || "", "root"]); + } + + eventTags.push(["e", replyEvent.id, replyEvent.relay || "", "reply"]); + eventTags.push(["p", replyEvent.pubkey]); + } + } + const cmd: string = await invoke("publish", { content: eventContent, tags: eventTags, @@ -310,6 +323,16 @@ export class Ark { } } + public async get_contact_metadata() { + try { + const cmd: Contact[] = await invoke("get_contact_metadata"); + return cmd; + } catch (e) { + console.error(e); + return []; + } + } + public async follow(id: string, alias?: string) { try { const cmd: string = await invoke("follow", { id, alias }); @@ -433,10 +456,18 @@ export class Ark { }); } - public open_editor() { + public open_editor(reply_to?: string, quote: boolean = false) { + let url: string; + + if (reply_to) { + url = `/editor?reply_to=${reply_to}"e=${quote}`; + } else { + url = "/editor"; + } + return new WebviewWindow("editor", { title: "Editor", - url: "/editor", + url, minWidth: 500, width: 600, height: 400, diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 2ad8e838..61ebf832 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -36,6 +36,7 @@ export interface Event { tags: string[][]; content: string; sig: string; + relay?: string; } export interface EventWithReplies extends Event { diff --git a/packages/ui/src/note/buttons/reply.tsx b/packages/ui/src/note/buttons/reply.tsx index d1302412..ba55f974 100644 --- a/packages/ui/src/note/buttons/reply.tsx +++ b/packages/ui/src/note/buttons/reply.tsx @@ -1,8 +1,9 @@ -import { ReplyIcon } from "@lume/icons"; +import { ReplyIcon, ShareIcon } from "@lume/icons"; import * as Tooltip from "@radix-ui/react-tooltip"; import { useTranslation } from "react-i18next"; import { useNoteContext } from "../provider"; import { useArk } from "@lume/ark"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; export function NoteReply() { const ark = useArk(); @@ -11,24 +12,52 @@ export function NoteReply() { const { t } = useTranslation(); return ( - - - - - - - - {t("note.menu.viewThread")} - - - - - + + + + + + + + + + + {t("note.menu.viewThread")} + + + + + + + + + + + + + + + + + ); } diff --git a/packages/ui/src/note/buttons/repost.tsx b/packages/ui/src/note/buttons/repost.tsx index db0144a3..f0f348da 100644 --- a/packages/ui/src/note/buttons/repost.tsx +++ b/packages/ui/src/note/buttons/repost.tsx @@ -1,8 +1,7 @@ import { LoaderIcon, ReplyIcon, RepostIcon } from "@lume/icons"; -import { cn, editorAtom, editorValueAtom } from "@lume/utils"; +import { cn } from "@lume/utils"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as Tooltip from "@radix-ui/react-tooltip"; -import { useSetAtom } from "jotai"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -12,13 +11,10 @@ import { useArk } from "@lume/ark"; export function NoteRepost() { const ark = useArk(); const event = useNoteContext(); - const setEditorValue = useSetAtom(editorValueAtom); - const setIsEditorOpen = useSetAtom(editorAtom); const [t] = useTranslation(); const [loading, setLoading] = useState(false); const [isRepost, setIsRepost] = useState(false); - const [open, setOpen] = useState(false); const repost = async () => { try { @@ -39,35 +35,15 @@ export function NoteRepost() { } }; - const quote = () => { - setEditorValue([ - { - type: "paragraph", - children: [{ text: "" }], - }, - { - type: "event", - // @ts-expect-error, useless - eventId: `nostr:${nip19.noteEncode(event.id)}`, - children: [{ text: "" }], - }, - { - type: "paragraph", - children: [{ text: "" }], - }, - ]); - setIsEditorOpen(true); - }; - return ( - + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4367c1d2..84ccc0bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@radix-ui/react-collapsible': specifier: ^1.0.3 version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0)(react@18.2.0) + '@tanstack/query-sync-storage-persister': + specifier: ^5.24.1 + version: 5.24.1 '@tanstack/react-query': specifier: ^5.22.2 version: 5.22.2(react@18.2.0) @@ -96,6 +99,9 @@ importers: idb-keyval: specifier: ^6.2.1 version: 6.2.1 + nostr-tools: + specifier: ^2.3.1 + version: 2.3.1(typescript@5.3.3) react: specifier: ^18.2.0 version: 18.2.0 @@ -2729,15 +2735,15 @@ packages: resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} dependencies: '@noble/curves': 1.1.0 - '@noble/hashes': 1.3.1 - '@scure/base': 1.1.1 + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.5 dev: false /@scure/bip39@1.2.1: resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} dependencies: - '@noble/hashes': 1.3.1 - '@scure/base': 1.1.1 + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.5 dev: false /@swc/core-darwin-arm64@1.4.2: @@ -2892,12 +2898,29 @@ packages: resolution: {integrity: sha512-z3PwKFUFACMUqe1eyesCIKg3Jv1mysSrYfrEW5ww5DCDUD4zlpTKBvUDaEjsfZzL3ULrFLDM9yVUxI/fega1Qg==} dev: false + /@tanstack/query-core@5.24.1: + resolution: {integrity: sha512-DZ6Nx9p7BhjkG50ayJ+MKPgff+lMeol7QYXkvuU5jr2ryW/4ok5eanaS9W5eooA4xN0A/GPHdLGOZGzArgf5Cg==} + dev: false + /@tanstack/query-persist-client-core@5.22.2: resolution: {integrity: sha512-sFDgWoN54uclIDIoImPmDzxTq8HhZEt9pO0JbVHjI6LPZqunMMF9yAq9zFKrpH//jD5f+rBCQsdGyhdpUo9e8Q==} dependencies: '@tanstack/query-core': 5.22.2 dev: false + /@tanstack/query-persist-client-core@5.24.1: + resolution: {integrity: sha512-ayUDCSCXAq3ZYXMrVQ3c4g2Mvj+d/Q7rGkNJTvdw09DZQUUMTfZsvSayitJjOxqJl1Pex4HmZNk8PiDvrqvlRQ==} + dependencies: + '@tanstack/query-core': 5.24.1 + dev: false + + /@tanstack/query-sync-storage-persister@5.24.1: + resolution: {integrity: sha512-dfqgFgb+6tmdvnE1vMQbBuZOBUi7zFeQB/gQJgiADJ2IO0OXp/Ucj06sVOLm9fAPGiUBDqF+UW/xB9ipBH2+Hw==} + dependencies: + '@tanstack/query-core': 5.24.1 + '@tanstack/query-persist-client-core': 5.24.1 + dev: false + /@tanstack/react-query-persist-client@5.22.2(@tanstack/react-query@5.22.2)(react@18.2.0): resolution: {integrity: sha512-osAaQn2PDTaa2ApTLOAus7g8Y96LHfS2+Pgu/RoDlEJUEkX7xdEn0YuurxbnJaDJDESMfr+CH/eAX2y+lx02Fg==} peerDependencies: diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b1dcd049..7f26ab5b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -98,6 +98,7 @@ fn main() { nostr::keys::verify_nip05, nostr::metadata::get_profile, nostr::metadata::get_contact_list, + nostr::metadata::get_contact_metadata, nostr::metadata::create_profile, nostr::metadata::follow, nostr::metadata::unfollow, @@ -110,7 +111,6 @@ fn main() { nostr::event::get_global_events, nostr::event::get_event_thread, nostr::event::publish, - nostr::event::reply_to, nostr::event::repost, nostr::event::upvote, nostr::event::downvote, diff --git a/src-tauri/src/nostr/event.rs b/src-tauri/src/nostr/event.rs index 9a837e7c..1ce905bc 100644 --- a/src-tauri/src/nostr/event.rs +++ b/src-tauri/src/nostr/event.rs @@ -137,25 +137,6 @@ pub async fn publish( } } -#[tauri::command] -pub async fn reply_to( - content: &str, - tags: Vec, - state: State<'_, Nostr>, -) -> Result { - let client = &state.client; - if let Ok(event_tags) = Tag::parse(tags) { - let event = client - .publish_text_note(content, vec![event_tags]) - .await - .expect("Publish reply failed"); - - Ok(event) - } else { - Err("Reply failed".into()) - } -} - #[tauri::command] pub async fn repost(id: &str, pubkey: &str, state: State<'_, Nostr>) -> Result { let client = &state.client; diff --git a/src-tauri/src/nostr/metadata.rs b/src-tauri/src/nostr/metadata.rs index bf978f4e..808b1a05 100644 --- a/src-tauri/src/nostr/metadata.rs +++ b/src-tauri/src/nostr/metadata.rs @@ -3,6 +3,12 @@ use nostr_sdk::prelude::*; use std::{str::FromStr, time::Duration}; use tauri::State; +#[derive(serde::Serialize)] +pub struct CacheContact { + pubkey: String, + profile: Metadata, +} + #[tauri::command] pub async fn get_profile(id: &str, state: State<'_, Nostr>) -> Result { let client = &state.client; @@ -46,11 +52,36 @@ pub async fn get_profile(id: &str, state: State<'_, Nostr>) -> Result) -> Result, String> { let client = &state.client; - let contact_list = client.get_contact_list(Some(Duration::from_secs(10))).await; - if let Ok(list) = contact_list { - let v = list.into_iter().map(|f| f.public_key.to_hex()).collect(); - Ok(v) + if let Ok(contact_list) = client.get_contact_list(Some(Duration::from_secs(10))).await { + let list = contact_list + .into_iter() + .map(|f| f.public_key.to_hex()) + .collect(); + + Ok(list) + } else { + Err("Contact list not found".into()) + } +} + +#[tauri::command] +pub async fn get_contact_metadata(state: State<'_, Nostr>) -> Result, String> { + let client = &state.client; + + if let Ok(contact_list) = client + .get_contact_list_metadata(Some(Duration::from_secs(10))) + .await + { + let list: Vec = contact_list + .into_iter() + .map(|(id, metadata)| CacheContact { + pubkey: id.to_hex(), + profile: metadata, + }) + .collect(); + + Ok(list) } else { Err("Contact list not found".into()) }