feat: repost with multi-account

This commit is contained in:
reya 2024-10-20 09:29:24 +07:00
parent 033272fd6d
commit b1efc33401
28 changed files with 503 additions and 297 deletions

View File

@ -157,6 +157,26 @@ pub fn delete_account(id: String) -> Result<(), String> {
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
match client.signer().await {
Ok(signer) => {
let signer_key = signer.public_key().await.unwrap();
if signer_key == public_key {
Ok(true)
} else {
Ok(false)
}
}
Err(_) => Ok(false),
}
}
#[tauri::command]
#[specta::specta]
pub async fn set_signer(

View File

@ -410,16 +410,58 @@ pub async fn repost(raw: String, state: State<'_, Nostr>) -> Result<String, Stri
#[tauri::command]
#[specta::specta]
pub async fn delete(id: String, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn is_reposted(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let accounts = state.accounts.lock().unwrap().clone();
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
match client.delete_event(event_id).await {
Ok(event_id) => Ok(event_id.to_string()),
let authors: Vec<PublicKey> = accounts
.iter()
.map(|acc| PublicKey::from_str(acc).unwrap())
.collect();
let filter = Filter::new()
.event(event_id)
.kind(Kind::Repost)
.authors(authors);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
match client.delete_event(event_id).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.author(public_key)
.event(event_id)
.kind(Kind::EventDeletion);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> {
@ -498,34 +540,3 @@ pub async fn search(query: String, state: State<'_, Nostr>) -> Result<Vec<RichEv
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.author(public_key)
.event(event_id)
.kind(Kind::EventDeletion);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let builder = EventBuilder::delete(vec![event_id]);
match client.send_event_builder(builder).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}

View File

@ -22,6 +22,7 @@ pub struct Window {
maximizable: bool,
minimizable: bool,
hidden_title: bool,
closable: bool,
}
#[derive(Serialize, Deserialize, Type)]
@ -109,7 +110,7 @@ pub fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<bool
#[tauri::command]
#[specta::specta]
#[cfg(target_os = "macos")]
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<String, String> {
if let Some(current_window) = app_handle.get_window(&window.label) {
if current_window.is_visible().unwrap_or_default() {
let _ = current_window.set_focus();
@ -117,6 +118,8 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
let _ = current_window.show();
let _ = current_window.set_focus();
};
Ok(current_window.label().to_string())
} else {
let new_window = WebviewWindowBuilder::new(
&app_handle,
@ -131,6 +134,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.minimizable(window.minimizable)
.maximizable(window.maximizable)
.transparent(true)
.closable(window.closable)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::UnderWindowBackground],
@ -142,24 +146,26 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
// Restore native border
new_window.add_border(None);
}
Ok(())
Ok(new_window.label().to_string())
}
}
#[tauri::command(async)]
#[specta::specta]
#[cfg(target_os = "windows")]
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app_handle.get_window(&window.label) {
if window.is_visible().unwrap_or_default() {
let _ = window.set_focus();
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<String, String> {
if let Some(current_window) = app_handle.get_window(&window.label) {
if current_window.is_visible().unwrap_or_default() {
let _ = current_window.set_focus();
} else {
let _ = window.show();
let _ = window.set_focus();
let _ = current_window.show();
let _ = current_window.set_focus();
};
Ok(current_window.label().to_string())
} else {
let window = WebviewWindowBuilder::new(
let new_window = WebviewWindowBuilder::new(
&app_handle,
&window.label,
WebviewUrl::App(PathBuf::from(window.url)),
@ -171,6 +177,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.maximizable(window.maximizable)
.transparent(true)
.decorations(false)
.closable(window.closable)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::Mica],
@ -181,7 +188,9 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.unwrap();
// Set decoration
window.create_overlay_titlebar().unwrap();
new_window.create_overlay_titlebar().unwrap();
Ok(new_window.label().to_string())
}
Ok(())

View File

@ -105,6 +105,7 @@ fn main() {
get_private_key,
delete_account,
reset_password,
has_signer,
set_signer,
get_profile,
set_profile,
@ -139,12 +140,13 @@ fn main() {
get_all_events_by_hashtags,
get_local_events,
get_global_events,
is_deleted_event,
request_delete,
search,
publish,
reply,
repost,
is_reposted,
request_delete,
is_deleted_event,
event_to_bech32,
user_to_bech32,
create_column,

View File

@ -99,6 +99,14 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
else return { status: "error", error: e as any };
}
},
async hasSigner(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("has_signer", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setSigner(account: string, password: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_signer", { account, password }) };
@ -371,22 +379,6 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
else return { status: "error", error: e as any };
}
},
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestDelete(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async search(query: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("search", { query }) };
@ -419,6 +411,30 @@ async repost(raw: string) : Promise<Result<string, string>> {
else return { status: "error", error: e as any };
}
},
async isReposted(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_reposted", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestDelete(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
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 }) };
@ -467,7 +483,7 @@ async closeColumn(label: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async openWindow(window: Window) : Promise<Result<null, string>> {
async openWindow(window: Window) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
} catch (e) {
@ -511,7 +527,7 @@ export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; trusted_only: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean }
export type SubKind = "Subscribe" | "Unsubscribe"
export type Subscription = { label: string; kind: SubKind; event_id: string | null; contacts: string[] | null }
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean; closable: boolean }
/** tauri-specta globals **/

View File

@ -1,47 +0,0 @@
import { cn } from "@/commons";
import { Note } from "@/components/note";
import type { LumeEvent } from "@/system";
import { ChatsTeardrop } from "@phosphor-icons/react";
import { memo, useMemo } from "react";
export const Conversation = memo(function Conversation({
event,
className,
}: {
event: LumeEvent;
className?: string;
}) {
const thread = useMemo(() => event.thread, [event]);
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div className="flex flex-col gap-3">
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<ChatsTeardrop className="size-4" />
Thread
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
<div>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
</div>
<div className="flex items-center px-3 h-14">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
});

View File

@ -1,5 +1,4 @@
import { cn } from "@/commons";
import { useRouteContext } from "@tanstack/react-router";
import type { ReactNode } from "react";
export function Frame({
@ -7,8 +6,6 @@ export function Frame({
shadow,
className,
}: { children: ReactNode; shadow?: boolean; className?: string }) {
const { platform } = useRouteContext({ strict: false });
return (
<div
className={cn(

View File

@ -0,0 +1,23 @@
import type { SVGProps } from "react";
export const QuoteIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M2.75 5.75a2 2 0 0 1 2-2h14.5a2 2 0 0 1 2 2v10.5a2 2 0 0 1-2 2h-3.874a1 1 0 0 0-.638.23l-2.098 1.738a1 1 0 0 1-1.28-.003l-2.066-1.731a1 1 0 0 0-.642-.234H4.75a2 2 0 0 1-2-2z"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
/>
<path
d="M9.523 8C8.406 8 7.5 8.91 7.5 10.033a2.028 2.028 0 0 0 2.81 1.874q-.072.132-.157.251c-.353.502-.875.885-1.554 1.34a.453.453 0 0 0-.125.626.45.45 0 0 0 .624.125c.67-.449 1.328-.913 1.79-1.569.474-.674.716-1.51.658-2.66A2.03 2.03 0 0 0 9.523 8m4.945 0c-1.117 0-2.023.91-2.023 2.033a2.028 2.028 0 0 0 2.81 1.874q-.072.132-.156.251c-.353.502-.876.885-1.554 1.34a.453.453 0 0 0-.125.626.45.45 0 0 0 .623.125c.67-.449 1.328-.913 1.79-1.569.474-.674.717-1.51.658-2.66A2.03 2.03 0 0 0 14.468 8"
fill="currentColor"
/>
</svg>
);

View File

@ -4,10 +4,8 @@ export * from "./spinner";
export * from "./column";
// Newsfeed
export * from "./repost";
export * from "./conversation";
export * from "./quote";
export * from "./text";
export * from "./repost";
export * from "./reply";
// Global components
@ -18,3 +16,4 @@ export * from "./user";
export * from "./icons/reply";
export * from "./icons/repost";
export * from "./icons/zap";
export * from "./icons/quote";

View File

@ -8,7 +8,7 @@ export function NoteOpenThread() {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<button
type="button"

View File

@ -0,0 +1,40 @@
import { cn } from "@/commons";
import { QuoteIcon } from "@/components";
import { LumeWindow } from "@/system";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider";
export function NoteQuote({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const event = useNoteContext();
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => LumeWindow.openEditor(null, event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
>
<QuoteIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
{label ? "Quote" : null}
</button>
</Tooltip.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">
Quote
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@ -18,10 +18,8 @@ export function NoteReply({
type="button"
onClick={() => LumeWindow.openEditor(event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
label ? "w-24 gap-1.5" : "w-14",
)}
>
<ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />

View File

@ -1,88 +1,174 @@
import { appSettings, cn } from "@/commons";
import { commands } from "@/commands.gen";
import { appSettings, cn, displayNpub } from "@/commons";
import { RepostIcon, Spinner } from "@/components";
import { LumeWindow } from "@/system";
import type { Metadata } from "@/types";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react";
import type { Window } from "@tauri-apps/api/window";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useNoteContext } from "../provider";
export function NoteRepost({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const visible = useStore(appSettings, (state) => state.display_repost_button);
const event = useNoteContext();
const visible = useStore(appSettings, (state) => state.display_repost_button);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const { isLoading, data: status } = useQuery({
queryKey: ["is-reposted", event.id],
queryFn: async () => {
const res = await commands.isReposted(event.id);
if (res.status === "ok") {
return res.data;
} else {
return false;
}
},
enabled: visible,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
});
const repost = async () => {
if (isRepost) return;
try {
setLoading(true);
// repost
await event.repost();
// update state
setLoading(false);
setIsRepost(true);
} catch {
setLoading(false);
await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
}
};
const [isPending, startTransition] = useTransition();
const [popup, setPopup] = useState<Window>(null);
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Repost",
action: async () => repost(),
}),
MenuItem.new({
text: "Quote",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const accounts = await commands.getAccounts();
const list = [];
const menu = await Menu.new({
items: menuItems,
});
for (const account of accounts) {
const res = await commands.getProfile(account);
let name = "unknown";
if (res.status === "ok") {
const profile: Metadata = JSON.parse(res.data);
name = profile.display_name ?? profile.name;
}
list.push(
MenuItem.new({
text: `Repost as ${name} (${displayNpub(account, 16)})`,
action: async () => submit(account),
}),
);
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e));
}, []);
const repost = useMutation({
mutationFn: async () => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["is-reposted", event.id] });
// Optimistically update to the new value
queryClient.setQueryData(["is-reposted", event.id], true);
const res = await commands.repost(JSON.stringify(event.raw));
if (res.status === "ok") {
return;
} else {
throw new Error(res.error);
}
},
onError: () => {
queryClient.setQueryData(["is-reposted", event.id], false);
},
onSettled: async () => {
return await queryClient.invalidateQueries({
queryKey: ["is-reposted", event.id],
});
},
});
const submit = (account: string) => {
startTransition(async () => {
if (!status) {
const signer = await commands.hasSigner(account);
if (signer.status === "ok") {
if (!signer.data) {
const newPopup = await LumeWindow.openPopup(
`/set-signer?account=${account}`,
undefined,
false,
);
setPopup(newPopup);
return;
}
repost.mutate();
} else {
return;
}
} else {
return;
}
});
};
useEffect(() => {
if (!visible) return;
if (!popup) return;
const unlisten = popup.listen("signer-updated", async () => {
repost.mutate();
});
return () => {
unlisten.then((f) => f());
};
}, [popup]);
if (!visible) return null;
return (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn(
smol ? "size-4" : "size-5",
isRepost ? "text-blue-500" : "",
)}
/>
)}
{label ? "Repost" : null}
</button>
<Tooltip.Provider>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
label ? "w-24 gap-1.5" : "w-14",
)}
>
{isPending || isLoading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn(
smol ? "size-4" : "size-5",
status ? "text-blue-500" : "",
)}
/>
)}
{label ? "Repost" : null}
</button>
</Tooltip.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">
Repost
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@ -20,10 +20,8 @@ export function NoteZap({
type="button"
onClick={() => LumeWindow.openZap(event.id, search.account)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
label ? "w-24 gap-1.5" : "w-14",
)}
>
<ZapIcon className={smol ? "size-4" : "size-5"} />

View File

@ -1,4 +1,5 @@
import { NoteOpenThread } from "./buttons/open";
import { NoteQuote } from "./buttons/quote";
import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap";
@ -16,6 +17,7 @@ export const Note = {
User: NoteUser,
Menu: NoteMenu,
Reply: NoteReply,
Quote: NoteQuote,
Repost: NoteRepost,
Content: NoteContent,
ContentLarge: NoteContentLarge,

View File

@ -51,11 +51,11 @@ export const MentionNote = memo(function MentionNote({
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
</span>
<div className="invisible group-hover:visible flex items-center justify-end gap-3">
<div className="invisible group-hover:visible flex items-center justify-end">
<button
type="button"
onClick={() => LumeWindow.openEvent(event)}
className="text-sm font-medium text-blue-500 hover:text-blue-600"
className="mr-3 text-sm font-medium text-blue-500 hover:text-blue-600"
>
Show all
</button>

View File

@ -1,39 +0,0 @@
import { cn } from "@/commons";
import { Note } from "@/components/note";
import type { LumeEvent } from "@/system";
import { Quotes } from "@phosphor-icons/react";
import { memo } from "react";
export const Quote = memo(function Quote({
event,
className,
}: {
event: LumeEvent;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root className={cn("", className)}>
<div className="flex flex-col gap-3">
<Note.Child event={event.quote} isRoot />
<div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<Quotes className="size-4" />
Quote
</div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div>
<div>
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
</div>
<Note.Content className="px-3" quote={false} clean />
</div>
</div>
<div className="flex items-center px-3 h-14">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
});

View File

@ -1,21 +1,12 @@
import { commands } from "@/commands.gen";
import { appSettings, cn, replyTime } from "@/commons";
import { cn, replyTime } from "@/commons";
import { Note } from "@/components/note";
import { type LumeEvent, LumeWindow } from "@/system";
import { CaretDown } from "@phosphor-icons/react";
import { Link, useSearch } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools";
import {
type ReactNode,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { type ReactNode, memo, useCallback, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./note/mentions/hashtag";
import { MentionUser } from "./note/mentions/user";
@ -28,11 +19,7 @@ export const ReplyNote = memo(function ReplyNote({
event: LumeEvent;
className?: string;
}) {
const trustedOnly = useStore(appSettings, (state) => state.trusted_only);
const search = useSearch({ strict: false });
const [isTrusted, setIsTrusted] = useState<boolean>(null);
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
@ -57,24 +44,6 @@ export const ReplyNote = memo(function ReplyNote({
await menu.popup().catch((e) => console.error(e));
}, []);
useEffect(() => {
async function check() {
const res = await commands.isTrustedUser(event.pubkey);
if (res.status === "ok") {
setIsTrusted(res.data);
}
}
if (trustedOnly) {
check();
}
}, []);
if (isTrusted !== null && isTrusted === false) {
return null;
}
return (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
@ -99,7 +68,7 @@ export const ReplyNote = memo(function ReplyNote({
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
</span>
<div className="flex items-center justify-end gap-5">
<div className="flex items-center justify-end">
<Note.Reply smol />
<Note.Repost smol />
<Note.Zap smol />
@ -180,7 +149,7 @@ function ChildReply({ event }: { event: LumeEvent }) {
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
</span>
<div className="invisible group-hover:visible flex items-center justify-end gap-5">
<div className="invisible group-hover:visible flex items-center justify-end">
<Note.Reply smol />
<Note.Repost smol />
<Note.Zap smol />

View File

@ -36,7 +36,7 @@ export const RepostNote = memo(function RepostNote({
</div>
<Note.Content className="px-3" />
<div className="flex items-center justify-between px-3 mt-3 h-14">
<div className="inline-flex items-center gap-6">
<div className="inline-flex items-center gap-2">
<Note.Open />
<Note.Reply />
<Note.Repost />

View File

@ -18,7 +18,7 @@ export const TextNote = memo(function TextNote({
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="flex items-center gap-6 px-3 mt-3 h-14">
<div className="flex items-center gap-2 px-3 mt-3 h-14">
<Note.Open />
<Note.Reply />
<Note.Repost />

View File

@ -13,6 +13,7 @@ import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as SetSignerImport } from './routes/set-signer'
import { Route as SetInterestImport } from './routes/set-interest'
import { Route as SetGroupImport } from './routes/set-group'
import { Route as BootstrapRelaysImport } from './routes/bootstrap-relays'
@ -91,6 +92,11 @@ const SettingsLazyRoute = SettingsLazyImport.update({
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/_settings.lazy').then((d) => d.Route))
const SetSignerRoute = SetSignerImport.update({
path: '/set-signer',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/set-signer.lazy').then((d) => d.Route))
const SetInterestRoute = SetInterestImport.update({
path: '/set-interest',
getParentRoute: () => rootRoute,
@ -330,6 +336,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SetInterestImport
parentRoute: typeof rootRoute
}
'/set-signer': {
id: '/set-signer'
path: '/set-signer'
fullPath: '/set-signer'
preLoaderRoute: typeof SetSignerImport
parentRoute: typeof rootRoute
}
'/_settings': {
id: '/_settings'
path: ''
@ -662,6 +675,7 @@ export interface FileRoutesByFullPath {
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/set-group': typeof SetGroupRoute
'/set-interest': typeof SetInterestRoute
'/set-signer': typeof SetSignerRoute
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
'/bitcoin-connect': typeof SettingsBitcoinConnectRoute
@ -698,6 +712,7 @@ export interface FileRoutesByTo {
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/set-group': typeof SetGroupRoute
'/set-interest': typeof SetInterestRoute
'/set-signer': typeof SetSignerRoute
'': typeof SettingsLazyRouteWithChildren
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
@ -737,6 +752,7 @@ export interface FileRoutesById {
'/bootstrap-relays': typeof BootstrapRelaysRoute
'/set-group': typeof SetGroupRoute
'/set-interest': typeof SetInterestRoute
'/set-signer': typeof SetSignerRoute
'/_settings': typeof SettingsLazyRouteWithChildren
'/new': typeof NewLazyRoute
'/reset': typeof ResetLazyRoute
@ -778,6 +794,7 @@ export interface FileRouteTypes {
| '/bootstrap-relays'
| '/set-group'
| '/set-interest'
| '/set-signer'
| '/new'
| '/reset'
| '/bitcoin-connect'
@ -813,6 +830,7 @@ export interface FileRouteTypes {
| '/bootstrap-relays'
| '/set-group'
| '/set-interest'
| '/set-signer'
| ''
| '/new'
| '/reset'
@ -850,6 +868,7 @@ export interface FileRouteTypes {
| '/bootstrap-relays'
| '/set-group'
| '/set-interest'
| '/set-signer'
| '/_settings'
| '/new'
| '/reset'
@ -890,6 +909,7 @@ export interface RootRouteChildren {
BootstrapRelaysRoute: typeof BootstrapRelaysRoute
SetGroupRoute: typeof SetGroupRoute
SetInterestRoute: typeof SetInterestRoute
SetSignerRoute: typeof SetSignerRoute
SettingsLazyRoute: typeof SettingsLazyRouteWithChildren
NewLazyRoute: typeof NewLazyRoute
ResetLazyRoute: typeof ResetLazyRoute
@ -906,6 +926,7 @@ const rootRouteChildren: RootRouteChildren = {
BootstrapRelaysRoute: BootstrapRelaysRoute,
SetGroupRoute: SetGroupRoute,
SetInterestRoute: SetInterestRoute,
SetSignerRoute: SetSignerRoute,
SettingsLazyRoute: SettingsLazyRouteWithChildren,
NewLazyRoute: NewLazyRoute,
ResetLazyRoute: ResetLazyRoute,
@ -933,6 +954,7 @@ export const routeTree = rootRoute
"/bootstrap-relays",
"/set-group",
"/set-interest",
"/set-signer",
"/_settings",
"/new",
"/reset",
@ -959,6 +981,9 @@ export const routeTree = rootRoute
"/set-interest": {
"filePath": "set-interest.tsx"
},
"/set-signer": {
"filePath": "set-signer.tsx"
},
"/_settings": {
"filePath": "_settings.lazy.tsx",
"children": [

View File

@ -10,6 +10,7 @@ import { useEffect } from "react";
interface RouterContext {
queryClient: QueryClient;
platform: OsType;
account: string[];
}
export const Route = createRootRouteWithContext<RouterContext>()({

View File

@ -126,24 +126,20 @@ const Account = memo(function Account({ pubkey }: { pubkey: string }) {
async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
const items = await Promise.all([
MenuItem.new({
text: "New Post",
action: () => LumeWindow.openEditor(),
text: "View Profile",
action: () => LumeWindow.openProfile(pubkey),
}),
MenuItem.new({
text: "Profile",
action: () => LumeWindow.openProfile(pubkey),
text: "Copy Public Key",
action: async () => await writeText(pubkey),
}),
MenuItem.new({
text: "Settings",
action: () => LumeWindow.openSettings(pubkey),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy Public Key",
action: async () => await writeText(pubkey),
}),
MenuItem.new({
text: "Logout",
action: async () => {
@ -162,9 +158,7 @@ const Account = memo(function Account({ pubkey }: { pubkey: string }) {
}),
]);
const menu = await Menu.new({
items: menuItems,
});
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e));
},

View File

@ -134,7 +134,7 @@ function Groups() {
</button>
<button
type="button"
onClick={() => LumeWindow.openPopup("New group", "/set-group")}
onClick={() => LumeWindow.openPopup("/set-group", "New group")}
className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
>
<Plus className="size-3" weight="bold" />
@ -252,7 +252,7 @@ function Interests() {
<button
type="button"
onClick={() =>
LumeWindow.openPopup("New interest", "/set-interest")
LumeWindow.openPopup("/set-interest", "New interest")
}
className="h-7 w-max px-2 inline-flex items-center justify-center gap-1 text-sm font-medium rounded-full bg-neutral-300 dark:bg-neutral-700 hover:bg-blue-500 hover:text-white"
>

View File

@ -0,0 +1,95 @@
import { commands } from "@/commands.gen";
import { Spinner, User } from "@/components";
import { ArrowRight } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useState, useTransition } from "react";
export const Route = createLazyFileRoute("/set-signer")({
component: Screen,
});
function Screen() {
const { account } = Route.useSearch();
const [password, setPassword] = useState("");
const [isPending, startTransition] = useTransition();
const unlock = () => {
startTransition(async () => {
if (!password.length) {
await message("Password is required", { kind: "info" });
return;
}
const window = getCurrentWindow();
const res = await commands.setSigner(account, password);
if (res.status === "ok") {
await window.emit("signer-updated", {});
await window.close();
} else {
await message(res.error, { kind: "error" });
return;
}
});
};
if (!account) {
return null;
}
return (
<div
data-tauri-drag-region
className="size-full flex flex-col items-center justify-between gap-6 p-3"
>
<div
data-tauri-drag-region
className="flex-1 w-full px-10 flex flex-col gap-6 items-center justify-center"
>
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-12 rounded-full" />
</User.Root>
</User.Provider>
<div className="w-full flex gap-2 items-center justify-center">
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") unlock();
}}
disabled={isPending}
placeholder="Enter password to unlock"
className="px-3 flex-1 rounded-full h-10 bg-transparent border border-black/10 dark:border-white/10 focus:border-blue-500 focus:outline-none placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => unlock()}
disabled={isPending}
className="shrink-0 size-10 inline-flex items-center justify-center rounded-full bg-blue-500 text-white"
>
{isPending ? (
<Spinner className="size-4" />
) : (
<ArrowRight className="size-4" weight="bold" />
)}
</button>
</div>
</div>
<div className="mt-auto">
<button
type="button"
onClick={() => getCurrentWindow().close()}
className="text-sm font-medium text-neutral-500"
>
Cancel
</button>
</div>
</div>
);
}

13
src/routes/set-signer.tsx Normal file
View File

@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
export interface RouteSearch {
account?: string;
}
export const Route = createFileRoute("/set-signer")({
validateSearch: (search: Record<string, string>): RouteSearch => {
return {
account: search.account,
};
},
});

View File

@ -12,10 +12,10 @@ export class LumeEvent {
public meta: Meta;
public relay?: string;
public replies?: LumeEvent[];
#raw: NostrEvent;
public raw: NostrEvent;
constructor(event: NostrEvent) {
this.#raw = event;
this.raw = event;
Object.assign(this, event);
}
@ -134,16 +134,6 @@ export class LumeEvent {
}
}
public async repost() {
const query = await commands.repost(JSON.stringify(this.#raw));
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async publish(
content: string,
warning?: string,

View File

@ -1,6 +1,6 @@
import { commands } from "@/commands.gen";
import type { LumeColumn, NostrEvent } from "@/types";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Window, getCurrentWindow } from "@tauri-apps/api/window";
import { nanoid } from "nanoid";
import type { LumeEvent } from "./event";
@ -120,6 +120,7 @@ export const LumeWindow = {
maximizable: false,
minimizable: false,
hidden_title: true,
closable: true,
});
if (query.status === "ok") {
@ -141,6 +142,7 @@ export const LumeWindow = {
maximizable: false,
minimizable: false,
hidden_title: true,
closable: true,
});
} else {
await LumeWindow.openSettings(account, "bitcoin-connect");
@ -156,6 +158,7 @@ export const LumeWindow = {
maximizable: false,
minimizable: false,
hidden_title: true,
closable: true,
});
if (query.status === "ok") {
@ -164,20 +167,21 @@ export const LumeWindow = {
throw new Error(query.error);
}
},
openPopup: async (title: string, url: string) => {
openPopup: async (url: string, title?: string, closable = true) => {
const query = await commands.openWindow({
label: `popup-${nanoid()}`,
url,
title,
title: title ?? "",
width: 400,
height: 500,
maximizable: false,
minimizable: false,
hidden_title: false,
hidden_title: !!title,
closable,
});
if (query.status === "ok") {
return query.data;
return await Window.getByLabel(query.data);
} else {
throw new Error(query.error);
}