feat: native context menu

This commit is contained in:
reya 2024-06-17 15:31:59 +07:00
parent 843895d876
commit d01cf8319d
10 changed files with 192 additions and 280 deletions

View File

@ -1,24 +1,23 @@
import { QuoteIcon, RepostIcon } from "@lume/icons";
import { RepostIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Spinner } from "@lume/ui";
import { useNoteContext } from "../provider";
import { LumeWindow } from "@lume/system";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
export function NoteRepost({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const repost = async () => {
if (isRepost) return;
try {
if (isRepost) return;
setLoading(true);
// repost
@ -30,71 +29,50 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
// notify
toast.success("You've reposted this post successfully");
} catch (e) {
} catch {
setLoading(false);
toast.error("Repost failed, try again later");
}
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Quote",
action: async () => repost(),
}),
MenuItem.new({
text: "Repost",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn("size-4", isRepost ? "text-blue-500" : "")}
/>
)}
{large ? "Repost" : null}
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="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] 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 dark:bg-neutral-50 dark:text-neutral-950">
{t("note.buttons.repost")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</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={() => repost()}
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<RepostIcon className="size-4" />
{t("note.buttons.repost")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => LumeWindow.openEditor(null, event.id)}
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<QuoteIcon className="size-4" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
)}
{large ? "Repost" : null}
</button>
);
}

View File

@ -1,100 +1,62 @@
import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { useNoteContext } from "./provider";
import { LumeWindow } from "@lume/system";
import { useCallback } from "react";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
export function NoteMenu() {
const { t } = useTranslation();
const event = useNoteContext();
const copyID = async () => {
await writeText(await event.idAsBech32());
};
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const copyRaw = async () => {
await writeText(JSON.stringify(event));
};
const menuItems = await Promise.all([
MenuItem.new({
text: "Copy Sharable Link",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
MenuItem.new({
text: "Copy Event ID",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(eventId);
},
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy Raw Event",
action: async () => {
event.meta = undefined;
const raw = JSON.stringify(event);
await writeText(raw);
},
}),
]);
const copyNpub = async () => {
await writeText(await event.pubkeyAsBech32());
};
const menu = await Menu.new({
items: menuItems,
});
const copyLink = async () => {
await writeText(`https://njump.me/${await event.idAsBech32()}`);
};
await menu.popup().catch((e) => console.error(e));
}, []);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
>
<HorizontalDotsIcon className="size-5" />
</button>
</DropdownMenu.Trigger>
<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={() => LumeWindow.openEvent(event)}
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewThread")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyLink")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyNoteId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyNpub()}
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyAuthorId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewAuthor")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px my-1 bg-neutral-900 dark:bg-neutral-100" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyRaw()}
className="inline-flex items-center gap-2 px-3 text-sm font-medium text-white rounded-lg h-9 hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyRaw")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
>
<HorizontalDotsIcon className="size-5" />
</button>
);
}

View File

@ -1,88 +0,0 @@
import { MentionIcon } from "@lume/icons";
import { cn, insertMention } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react";
import type { Contact } from "@lume/types";
import { toast } from "sonner";
import { User } from "@/components/user";
import { NostrAccount, NostrQuery } from "@lume/system";
export function MentionButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const [contacts, setContacts] = useState<string[]>([]);
const select = async (user: string) => {
try {
const metadata = await NostrQuery.getProfile(user);
const contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact);
} catch (e) {
toast.error(String(e));
}
};
useEffect(() => {
async function getContacts() {
const data = await NostrAccount.getContactList();
setContacts(data);
}
getContacts();
}, []);
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center",
className,
)}
>
<MentionIcon className="size-4" />
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="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] 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 dark:bg-neutral-50 dark:text-neutral-950">
Mention
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
{contacts.length < 1 ? (
<div className="flex items-center justify-center w-full h-full">
<p className="text-sm text-white">Contact List is empty.</p>
</div>
) : (
contacts.map((contact) => (
<DropdownMenu.Item
key={contact}
onClick={() => select(contact)}
className="flex items-center px-2 shrink-0 h-11 hover:bg-white/10"
>
<User.Provider pubkey={contact}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="rounded-full shrink-0 size-8" />
<User.Name className="text-sm font-medium text-white dark:text-black" />
</User.Root>
</User.Provider>
</DropdownMenu.Item>
))
)}
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@ -100,22 +100,6 @@ try {
else return { status: "error", error: e as any };
}
},
async eventToBech32(id: string, relays: string[]) : Promise<Result<string, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id, relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async userToBech32(key: string, relays: string[]) : Promise<Result<string, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("user_to_bech32", { key, relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async verifyNip05(key: string, nip05: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("verify_nip05", { key, nip05 }) };
@ -356,6 +340,22 @@ try {
else return { status: "error", error: e as any };
}
},
async eventToBech32(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async userToBech32(user: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("user_to_bech32", { user }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async showInFolder(path: string) : Promise<void> {
await TAURI_INVOKE("show_in_folder", { path });
},

View File

@ -150,7 +150,7 @@ export class LumeEvent {
}
public async idAsBech32() {
const query = await commands.eventToBech32(this.id, []);
const query = await commands.eventToBech32(this.id);
if (query.status === "ok") {
return query.data;
@ -160,7 +160,7 @@ export class LumeEvent {
}
public async pubkeyAsBech32() {
const query = await commands.userToBech32(this.pubkey, []);
const query = await commands.userToBech32(this.pubkey);
if (query.status === "ok") {
return query.data;

View File

@ -59,6 +59,8 @@
"fs:allow-read-file",
"theme:allow-set-theme",
"theme:allow-get-theme",
"menu:allow-new",
"menu:allow-popup",
"http:default",
"shell:allow-open",
{

View File

@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","panel","splash","settings","search","nwc","activity","zap-*","event-*","user-*","editor-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","window:allow-start-dragging","window:allow-create","window:allow-close","window:allow-set-focus","window:allow-center","window:allow-minimize","window:allow-maximize","window:allow-set-size","window:allow-set-focus","window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","http:default","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]}}
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","panel","splash","settings","search","nwc","activity","zap-*","event-*","user-*","editor-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","window:allow-start-dragging","window:allow-create","window:allow-close","window:allow-set-focus","window:allow-center","window:allow-minimize","window:allow-maximize","window:allow-set-size","window:allow-set-focus","window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","menu:allow-new","menu:allow-popup","http:default","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]}}

View File

@ -57,8 +57,6 @@ fn main() {
nostr::keys::get_private_key,
nostr::keys::connect_remote_account,
nostr::keys::load_account,
nostr::keys::event_to_bech32,
nostr::keys::user_to_bech32,
nostr::keys::verify_nip05,
nostr::metadata::get_current_user_profile,
nostr::metadata::get_profile,
@ -89,6 +87,8 @@ fn main() {
nostr::event::publish,
nostr::event::reply,
nostr::event::repost,
nostr::event::event_to_bech32,
nostr::event::user_to_bech32,
commands::folder::show_in_folder,
commands::window::create_column,
commands::window::close_column,

View File

@ -91,7 +91,7 @@ pub async fn get_event_from(
return Err(err.to_string());
}
if (client.connect_relay(relay_hint).await).is_ok() {
if client.connect_relay(relay_hint).await.is_ok() {
match event_id {
Some(id) => {
match client
@ -522,3 +522,79 @@ pub async fn repost(raw: &str, state: State<'_, Nostr>) -> Result<String, String
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn event_to_bech32(id: &str, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let event_id = match EventId::from_hex(id) {
Ok(id) => id,
Err(_) => return Err("ID is not valid.".into()),
};
let seens = client
.database()
.event_seen_on_relays(event_id)
.await
.unwrap();
match seens {
Some(set) => {
let relays = set.into_iter().collect::<Vec<_>>();
let event = Nip19Event::new(event_id, relays);
match event.to_bech32() {
Ok(id) => Ok(id),
Err(err) => Err(err.to_string()),
}
}
None => match event_id.to_bech32() {
Ok(id) => Ok(id),
Err(err) => Err(err.to_string()),
},
}
}
#[tauri::command]
#[specta::specta]
pub async fn user_to_bech32(user: &str, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key = match PublicKey::from_str(user) {
Ok(pk) => pk,
Err(_) => return Err("Public Key is not valid.".into()),
};
match client
.get_events_of(
vec![Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1)],
Some(Duration::from_secs(10)),
)
.await
{
Ok(events) => match events.first() {
Some(event) => {
let relay_list = nip65::extract_relay_list(event);
let relays = relay_list
.into_iter()
.map(|i| i.0.to_string())
.collect::<Vec<_>>();
let profile = Nip19Profile::new(public_key, relays).unwrap();
Ok(profile.to_bech32().unwrap())
}
None => match public_key.to_bech32() {
Ok(pk) => Ok(pk),
Err(err) => Err(err.to_string()),
},
},
Err(_) => match public_key.to_bech32() {
Ok(pk) => Ok(pk),
Err(err) => Err(err.to_string()),
},
}
}

View File

@ -383,24 +383,6 @@ pub fn get_private_key(npub: &str) -> Result<String, String> {
}
}
#[tauri::command]
#[specta::specta]
pub fn event_to_bech32(id: &str, relays: Vec<String>) -> Result<String, ()> {
let event_id = EventId::from_hex(id).unwrap();
let event = Nip19Event::new(event_id, relays);
Ok(event.to_bech32().unwrap())
}
#[tauri::command]
#[specta::specta]
pub fn user_to_bech32(key: &str, relays: Vec<String>) -> Result<String, ()> {
let pubkey = PublicKey::from_str(key).unwrap();
let profile = Nip19Profile::new(pubkey, relays).unwrap();
Ok(profile.to_bech32().unwrap())
}
#[tauri::command]
#[specta::specta]
pub async fn verify_nip05(key: &str, nip05: &str) -> Result<bool, String> {