feat: editor
This commit is contained in:
@@ -15,12 +15,14 @@
|
|||||||
"@lume/utils": "workspace:^",
|
"@lume/utils": "workspace:^",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
|
"@tanstack/query-sync-storage-persister": "^5.24.1",
|
||||||
"@tanstack/react-query": "^5.22.2",
|
"@tanstack/react-query": "^5.22.2",
|
||||||
"@tanstack/react-query-persist-client": "^5.22.2",
|
"@tanstack/react-query-persist-client": "^5.22.2",
|
||||||
"@tanstack/react-router": "^1.16.6",
|
"@tanstack/react-router": "^1.16.6",
|
||||||
"i18next": "^23.10.0",
|
"i18next": "^23.10.0",
|
||||||
"i18next-resources-to-backend": "^1.2.0",
|
"i18next-resources-to-backend": "^1.2.0",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
|
"nostr-tools": "^2.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^14.0.5",
|
"react-i18next": "^14.0.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useArk } from "@lume/ark";
|
import { useArk } from "@lume/ark";
|
||||||
import { ArkProvider } from "./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 { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
@@ -9,36 +9,23 @@ import "./app.css";
|
|||||||
import i18n from "./locale";
|
import i18n from "./locale";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { locale, platform } from "@tauri-apps/plugin-os";
|
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 { 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<PersistedClient>(idbValidKey);
|
|
||||||
},
|
|
||||||
removeClient: async () => {
|
|
||||||
await del(idbValidKey);
|
|
||||||
},
|
|
||||||
} as Persister;
|
|
||||||
}
|
|
||||||
|
|
||||||
const persister = createIDBPersister();
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const persister = createSyncStoragePersister({
|
||||||
|
storage: window.localStorage,
|
||||||
|
});
|
||||||
|
|
||||||
const platformName = await platform();
|
const platformName = await platform();
|
||||||
const osLocale = (await locale()).slice(0, 2);
|
const osLocale = (await locale()).slice(0, 2);
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import {
|
|||||||
insertImage,
|
insertImage,
|
||||||
insertMention,
|
insertMention,
|
||||||
insertNostrEvent,
|
insertNostrEvent,
|
||||||
isImagePath,
|
|
||||||
isImageUrl,
|
isImageUrl,
|
||||||
sendNativeNotification,
|
sendNativeNotification,
|
||||||
} from "@lume/utils";
|
} from "@lume/utils";
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MediaButton } from "./-components/media";
|
import { MediaButton } from "./-components/media";
|
||||||
@@ -34,23 +33,68 @@ import {
|
|||||||
} from "slate-react";
|
} from "slate-react";
|
||||||
import { Contact } from "@lume/types";
|
import { Contact } from "@lume/types";
|
||||||
import { User } from "@lume/ui";
|
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,
|
component: Screen,
|
||||||
|
pendingComponent: Pending,
|
||||||
});
|
});
|
||||||
|
|
||||||
function Screen() {
|
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 ark = useArk();
|
||||||
const ref = useRef<HTMLDivElement | null>();
|
const ref = useRef<HTMLDivElement | null>();
|
||||||
|
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [editorValue, setEditorValue] = useState([
|
const [editorValue, setEditorValue] = useState(initialValue);
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
children: [{ text: "" }],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
||||||
const [target, setTarget] = useState<Range | undefined>();
|
const [target, setTarget] = useState<Range | undefined>();
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -63,7 +107,7 @@ function Screen() {
|
|||||||
?.filter((c) =>
|
?.filter((c) =>
|
||||||
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
|
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
|
||||||
)
|
)
|
||||||
?.slice(0, 10);
|
?.slice(0, 5);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
// @ts-expect-error, backlog
|
// @ts-expect-error, backlog
|
||||||
@@ -101,7 +145,7 @@ function Screen() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const content = serialize(editor.children);
|
const content = serialize(editor.children);
|
||||||
const eventId = await ark.publish(content);
|
const eventId = await ark.publish(content, reply_to, quote);
|
||||||
|
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
await sendNativeNotification("You've publish new post successfully.");
|
await sendNativeNotification("You've publish new post successfully.");
|
||||||
@@ -162,7 +206,7 @@ function Screen() {
|
|||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2"
|
className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2"
|
||||||
>
|
>
|
||||||
<MediaButton className="size-9 rounded-full bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" />
|
<MediaButton className="size-9 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={publish}
|
onClick={publish}
|
||||||
@@ -176,8 +220,14 @@ function Screen() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full min-h-0 w-full">
|
<div className="flex h-full min-h-0 w-full">
|
||||||
<div className="h-full w-full flex-1 px-2 pb-2">
|
<div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2">
|
||||||
<div className="h-full w-full overflow-hidden overflow-y-auto rounded-xl bg-white p-5 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
{reply_to && !quote ? (
|
||||||
|
<div className="flex flex-col gap-2 rounded-xl bg-white p-5 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
||||||
|
<h3 className="font-medium">Reply to:</h3>
|
||||||
|
<MentionNote eventId={reply_to} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="h-full w-full flex-1 overflow-hidden overflow-y-auto rounded-xl bg-white p-5 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5">
|
||||||
<Editable
|
<Editable
|
||||||
key={JSON.stringify(editorValue)}
|
key={JSON.stringify(editorValue)}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
@@ -206,8 +256,8 @@ function Screen() {
|
|||||||
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={contact.pubkey}>
|
<User.Provider pubkey={contact.pubkey}>
|
||||||
<User.Root className="flex w-full items-center gap-2.5">
|
<User.Root className="flex w-full items-center gap-2">
|
||||||
<User.Avatar className="size-8 shrink-0 rounded-lg object-cover" />
|
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
|
||||||
<div className="flex w-full flex-col items-start">
|
<div className="flex w-full flex-col items-start">
|
||||||
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
|
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
|
||||||
</div>
|
</div>
|
||||||
@@ -226,6 +276,15 @@ function Screen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Pending() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center gap-2.5">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
<p>Loading cache...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const withNostrEvent = (editor: ReactEditor) => {
|
const withNostrEvent = (editor: ReactEditor) => {
|
||||||
const { insertData, isVoid } = editor;
|
const { insertData, isVoid } = editor;
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ function Screen() {
|
|||||||
onClick={() => select(account.npub)}
|
onClick={() => select(account.npub)}
|
||||||
>
|
>
|
||||||
<User.Provider pubkey={account.npub}>
|
<User.Provider pubkey={account.npub}>
|
||||||
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
|
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
|
||||||
<User.Avatar className="size-20 rounded-full object-cover" />
|
<User.Avatar className="size-20 rounded-full object-cover" />
|
||||||
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
|
||||||
</User.Root>
|
</User.Root>
|
||||||
@@ -91,7 +91,7 @@ function Screen() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<Link to="/landing">
|
<Link to="/landing">
|
||||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
|
||||||
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
|
||||||
<PlusIcon className="size-5" />
|
<PlusIcon className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
|
Contact,
|
||||||
Event,
|
Event,
|
||||||
EventWithReplies,
|
EventWithReplies,
|
||||||
Keys,
|
Keys,
|
||||||
@@ -12,11 +13,10 @@ import { readFile } from "@tauri-apps/plugin-fs";
|
|||||||
import { generateContentTags } from "@lume/utils";
|
import { generateContentTags } from "@lume/utils";
|
||||||
|
|
||||||
export class Ark {
|
export class Ark {
|
||||||
public account: Account;
|
public accounts: Account[];
|
||||||
public accounts: Array<Account>;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.account = { npub: "", contacts: [] };
|
this.accounts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get_all_accounts() {
|
public async get_all_accounts() {
|
||||||
@@ -43,12 +43,6 @@ export class Ark {
|
|||||||
npub: fullNpub,
|
npub: fullNpub,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cmd) {
|
|
||||||
const contacts: string[] = await invoke("get_contact_list");
|
|
||||||
this.account.npub = npub;
|
|
||||||
this.account.contacts = contacts;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -71,9 +65,6 @@ export class Ark {
|
|||||||
|
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
await invoke("update_signer", { nsec: keys.nsec });
|
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;
|
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 {
|
try {
|
||||||
const g = await generateContentTags(content);
|
const g = await generateContentTags(content);
|
||||||
|
|
||||||
const eventContent = g.content;
|
const eventContent = g.content;
|
||||||
const eventTags = g.tags;
|
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", {
|
const cmd: string = await invoke("publish", {
|
||||||
content: eventContent,
|
content: eventContent,
|
||||||
tags: eventTags,
|
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) {
|
public async follow(id: string, alias?: string) {
|
||||||
try {
|
try {
|
||||||
const cmd: string = await invoke("follow", { id, alias });
|
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", {
|
return new WebviewWindow("editor", {
|
||||||
title: "Editor",
|
title: "Editor",
|
||||||
url: "/editor",
|
url,
|
||||||
minWidth: 500,
|
minWidth: 500,
|
||||||
width: 600,
|
width: 600,
|
||||||
height: 400,
|
height: 400,
|
||||||
|
|||||||
1
packages/types/index.d.ts
vendored
1
packages/types/index.d.ts
vendored
@@ -36,6 +36,7 @@ export interface Event {
|
|||||||
tags: string[][];
|
tags: string[][];
|
||||||
content: string;
|
content: string;
|
||||||
sig: string;
|
sig: string;
|
||||||
|
relay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventWithReplies extends Event {
|
export interface EventWithReplies extends Event {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ReplyIcon } from "@lume/icons";
|
import { ReplyIcon, ShareIcon } from "@lume/icons";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
import { useArk } from "@lume/ark";
|
import { useArk } from "@lume/ark";
|
||||||
|
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
|
|
||||||
export function NoteReply() {
|
export function NoteReply() {
|
||||||
const ark = useArk();
|
const ark = useArk();
|
||||||
@@ -11,24 +12,52 @@ export function NoteReply() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<DropdownMenu.Root>
|
||||||
<Tooltip.Root delayDuration={150}>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Root delayDuration={150}>
|
||||||
<button
|
<DropdownMenu.Trigger asChild>
|
||||||
type="button"
|
<Tooltip.Trigger asChild>
|
||||||
onClick={() => ark.open_thread(event.id)}
|
<button
|
||||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
|
type="button"
|
||||||
>
|
className="size07 group inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||||
<ReplyIcon className="size-5 group-hover:text-blue-500" />
|
>
|
||||||
</button>
|
<ReplyIcon className="size-5 group-hover:text-blue-500" />
|
||||||
</Tooltip.Trigger>
|
</button>
|
||||||
<Tooltip.Portal>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content className="data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
|
</DropdownMenu.Trigger>
|
||||||
{t("note.menu.viewThread")}
|
<Tooltip.Portal>
|
||||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
<Tooltip.Content className="data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
</Tooltip.Content>
|
{t("note.menu.viewThread")}
|
||||||
</Tooltip.Portal>
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
</Tooltip.Root>
|
</Tooltip.Content>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => ark.open_thread(event.id)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
<ShareIcon className="size-4" />
|
||||||
|
{t("note.buttons.view")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => ark.open_editor(event.id)}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
<ReplyIcon className="size-4" />
|
||||||
|
{t("note.buttons.reply")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { LoaderIcon, ReplyIcon, RepostIcon } from "@lume/icons";
|
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 DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -12,13 +11,10 @@ import { useArk } from "@lume/ark";
|
|||||||
export function NoteRepost() {
|
export function NoteRepost() {
|
||||||
const ark = useArk();
|
const ark = useArk();
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const setEditorValue = useSetAtom(editorValueAtom);
|
|
||||||
const setIsEditorOpen = useSetAtom(editorAtom);
|
|
||||||
|
|
||||||
const [t] = useTranslation();
|
const [t] = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isRepost, setIsRepost] = useState(false);
|
const [isRepost, setIsRepost] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const repost = async () => {
|
const repost = async () => {
|
||||||
try {
|
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 (
|
return (
|
||||||
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
|
<DropdownMenu.Root>
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root delayDuration={150}>
|
<Tooltip.Root delayDuration={150}>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
|
className="size07 group inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="size-4 animate-spin" />
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
@@ -91,12 +67,12 @@ export function NoteRepost() {
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10">
|
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={repost}
|
onClick={repost}
|
||||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
<RepostIcon className="size-4" />
|
<RepostIcon className="size-4" />
|
||||||
{t("note.buttons.repost")}
|
{t("note.buttons.repost")}
|
||||||
@@ -105,13 +81,14 @@ export function NoteRepost() {
|
|||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={quote}
|
onClick={() => ark.open_editor(event.id, true)}
|
||||||
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
|
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
|
||||||
>
|
>
|
||||||
<ReplyIcon className="size-4" />
|
<ReplyIcon className="size-4" />
|
||||||
{t("note.buttons.quote")}
|
{t("note.buttons.quote")}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
|||||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -78,6 +78,9 @@ importers:
|
|||||||
'@radix-ui/react-collapsible':
|
'@radix-ui/react-collapsible':
|
||||||
specifier: ^1.0.3
|
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)
|
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':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.22.2
|
specifier: ^5.22.2
|
||||||
version: 5.22.2(react@18.2.0)
|
version: 5.22.2(react@18.2.0)
|
||||||
@@ -96,6 +99,9 @@ importers:
|
|||||||
idb-keyval:
|
idb-keyval:
|
||||||
specifier: ^6.2.1
|
specifier: ^6.2.1
|
||||||
version: 6.2.1
|
version: 6.2.1
|
||||||
|
nostr-tools:
|
||||||
|
specifier: ^2.3.1
|
||||||
|
version: 2.3.1(typescript@5.3.3)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.2.0
|
version: 18.2.0
|
||||||
@@ -2729,15 +2735,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==}
|
resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/curves': 1.1.0
|
'@noble/curves': 1.1.0
|
||||||
'@noble/hashes': 1.3.1
|
'@noble/hashes': 1.3.3
|
||||||
'@scure/base': 1.1.1
|
'@scure/base': 1.1.5
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@scure/bip39@1.2.1:
|
/@scure/bip39@1.2.1:
|
||||||
resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==}
|
resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 1.3.1
|
'@noble/hashes': 1.3.3
|
||||||
'@scure/base': 1.1.1
|
'@scure/base': 1.1.5
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@swc/core-darwin-arm64@1.4.2:
|
/@swc/core-darwin-arm64@1.4.2:
|
||||||
@@ -2892,12 +2898,29 @@ packages:
|
|||||||
resolution: {integrity: sha512-z3PwKFUFACMUqe1eyesCIKg3Jv1mysSrYfrEW5ww5DCDUD4zlpTKBvUDaEjsfZzL3ULrFLDM9yVUxI/fega1Qg==}
|
resolution: {integrity: sha512-z3PwKFUFACMUqe1eyesCIKg3Jv1mysSrYfrEW5ww5DCDUD4zlpTKBvUDaEjsfZzL3ULrFLDM9yVUxI/fega1Qg==}
|
||||||
dev: false
|
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:
|
/@tanstack/query-persist-client-core@5.22.2:
|
||||||
resolution: {integrity: sha512-sFDgWoN54uclIDIoImPmDzxTq8HhZEt9pO0JbVHjI6LPZqunMMF9yAq9zFKrpH//jD5f+rBCQsdGyhdpUo9e8Q==}
|
resolution: {integrity: sha512-sFDgWoN54uclIDIoImPmDzxTq8HhZEt9pO0JbVHjI6LPZqunMMF9yAq9zFKrpH//jD5f+rBCQsdGyhdpUo9e8Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.22.2
|
'@tanstack/query-core': 5.22.2
|
||||||
dev: false
|
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):
|
/@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==}
|
resolution: {integrity: sha512-osAaQn2PDTaa2ApTLOAus7g8Y96LHfS2+Pgu/RoDlEJUEkX7xdEn0YuurxbnJaDJDESMfr+CH/eAX2y+lx02Fg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ fn main() {
|
|||||||
nostr::keys::verify_nip05,
|
nostr::keys::verify_nip05,
|
||||||
nostr::metadata::get_profile,
|
nostr::metadata::get_profile,
|
||||||
nostr::metadata::get_contact_list,
|
nostr::metadata::get_contact_list,
|
||||||
|
nostr::metadata::get_contact_metadata,
|
||||||
nostr::metadata::create_profile,
|
nostr::metadata::create_profile,
|
||||||
nostr::metadata::follow,
|
nostr::metadata::follow,
|
||||||
nostr::metadata::unfollow,
|
nostr::metadata::unfollow,
|
||||||
@@ -110,7 +111,6 @@ fn main() {
|
|||||||
nostr::event::get_global_events,
|
nostr::event::get_global_events,
|
||||||
nostr::event::get_event_thread,
|
nostr::event::get_event_thread,
|
||||||
nostr::event::publish,
|
nostr::event::publish,
|
||||||
nostr::event::reply_to,
|
|
||||||
nostr::event::repost,
|
nostr::event::repost,
|
||||||
nostr::event::upvote,
|
nostr::event::upvote,
|
||||||
nostr::event::downvote,
|
nostr::event::downvote,
|
||||||
|
|||||||
@@ -137,25 +137,6 @@ pub async fn publish(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn reply_to(
|
|
||||||
content: &str,
|
|
||||||
tags: Vec<String>,
|
|
||||||
state: State<'_, Nostr>,
|
|
||||||
) -> Result<EventId, String> {
|
|
||||||
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]
|
#[tauri::command]
|
||||||
pub async fn repost(id: &str, pubkey: &str, state: State<'_, Nostr>) -> Result<EventId, ()> {
|
pub async fn repost(id: &str, pubkey: &str, state: State<'_, Nostr>) -> Result<EventId, ()> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ use nostr_sdk::prelude::*;
|
|||||||
use std::{str::FromStr, time::Duration};
|
use std::{str::FromStr, time::Duration};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct CacheContact {
|
||||||
|
pubkey: String,
|
||||||
|
profile: Metadata,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_profile(id: &str, state: State<'_, Nostr>) -> Result<Metadata, String> {
|
pub async fn get_profile(id: &str, state: State<'_, Nostr>) -> Result<Metadata, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
@@ -46,11 +52,36 @@ pub async fn get_profile(id: &str, state: State<'_, Nostr>) -> Result<Metadata,
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let contact_list = client.get_contact_list(Some(Duration::from_secs(10))).await;
|
|
||||||
|
|
||||||
if let Ok(list) = contact_list {
|
if let Ok(contact_list) = client.get_contact_list(Some(Duration::from_secs(10))).await {
|
||||||
let v = list.into_iter().map(|f| f.public_key.to_hex()).collect();
|
let list = contact_list
|
||||||
Ok(v)
|
.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<Vec<CacheContact>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
if let Ok(contact_list) = client
|
||||||
|
.get_contact_list_metadata(Some(Duration::from_secs(10)))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let list: Vec<CacheContact> = contact_list
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, metadata)| CacheContact {
|
||||||
|
pubkey: id.to_hex(),
|
||||||
|
profile: metadata,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(list)
|
||||||
} else {
|
} else {
|
||||||
Err("Contact list not found".into())
|
Err("Contact list not found".into())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user