wip: basic support multi windows

This commit is contained in:
reya 2024-02-16 09:58:07 +07:00
parent a0d9a729dd
commit 296b11b7b8
17 changed files with 214 additions and 118 deletions

View File

@ -1,7 +1,7 @@
import { useArk } from "@lume/ark"; import { useArk } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon, SearchIcon } from "@lume/icons"; import { ArrowRightCircleIcon, LoaderIcon, SearchIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { Event, Kind } from "@lume/types";
import { EmptyFeed, TextNote } from "@lume/ui"; import { EmptyFeed, RepostNote, TextNote } from "@lume/ui";
import { FETCH_LIMIT } from "@lume/utils"; import { FETCH_LIMIT } from "@lume/utils";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
@ -18,13 +18,13 @@ function Home() {
queryKey: ["timeline"], queryKey: ["timeline"],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_text_events(FETCH_LIMIT, pageParam); const events = await ark.get_text_events(FETCH_LIMIT, pageParam, true);
return events; return events;
}, },
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
const lastEvent = lastPage.at(-1); const lastEvent = lastPage.at(-1);
if (!lastEvent) return; if (!lastEvent) return;
return lastEvent.created_at; return lastEvent.created_at - 1;
}, },
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@ -32,8 +32,8 @@ function Home() {
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
switch (event.kind) { switch (event.kind) {
case Kind.Text: case Kind.Repost:
return <TextNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} />;
default: default:
return <TextNote key={event.id} event={event} />; return <TextNote key={event.id} event={event} />;
} }
@ -59,7 +59,7 @@ function Home() {
</a> </a>
</div> </div>
) : ( ) : (
<Virtualizer overscan={2}> <Virtualizer overscan={3}>
{data.map((item) => renderItem(item))} {data.map((item) => renderItem(item))}
</Virtualizer> </Virtualizer>
)} )}

View File

@ -0,0 +1,11 @@
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/events/$eventId")({
component: Event,
});
function Event() {
const { eventId } = Route.useParams();
return <div>{eventId}</div>;
}

View File

@ -0,0 +1,11 @@
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/users/$pubkey")({
component: User,
});
function User() {
const { pubkey } = Route.useParams();
return <div>{pubkey}</div>;
}

View File

@ -1,5 +1,6 @@
import type { CurrentAccount, Event, Keys, Metadata } from "@lume/types"; import type { CurrentAccount, Event, Keys, Metadata } from "@lume/types";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { WebviewWindow } from "@tauri-apps/api/webview";
export class Ark { export class Ark {
public account: CurrentAccount; public account: CurrentAccount;
@ -59,21 +60,56 @@ export class Ark {
public async get_event(id: string) { public async get_event(id: string) {
try { try {
const cmd: string = await invoke("get_event", { id }); const eventId: string = id
.replace("nostr:", "")
.split("'")[0]
.split(".")[0];
const cmd: string = await invoke("get_event", { id: eventId });
const event = JSON.parse(cmd) as Event; const event = JSON.parse(cmd) as Event;
return event; return event;
} catch (e) { } catch (e) {
return null; return null;
} }
} }
public async get_text_events(limit: number, asOf?: number) { public async get_text_events(limit: number, asOf?: number, dedup?: boolean) {
try { try {
let until: string = undefined; let until: string = undefined;
if (asOf && asOf > 0) until = asOf.toString(); if (asOf && asOf > 0) until = asOf.toString();
const cmd: Event[] = await invoke("get_text_events", { limit, until }); const seenIds = new Set<string>();
return cmd; const dedupQueue = new Set<string>();
const nostrEvents: Event[] = await invoke("get_text_events", {
limit,
until,
});
if (dedup) {
for (const event of nostrEvents) {
const tags = event.tags
.filter((el) => el[0] === "e")
?.map((item) => item[1]);
if (tags.length) {
for (const tag of tags) {
if (seenIds.has(tag)) {
dedupQueue.add(event.id);
break;
}
seenIds.add(tag);
}
}
}
return nostrEvents
.filter((event) => !dedupQueue.has(event.id))
.sort((a, b) => b.created_at - a.created_at);
}
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) { } catch (e) {
return []; return [];
} }
@ -170,9 +206,16 @@ export class Ark {
}; };
} }
public async get_profile(id: string) { public async get_profile(pubkey: string) {
try { try {
const id = pubkey
.replace("nostr:", "")
.split("'")[0]
.split(".")[0]
.split(",")[0]
.split("?")[0];
const cmd: Metadata = await invoke("get_profile", { id }); const cmd: Metadata = await invoke("get_profile", { id });
return cmd; return cmd;
} catch { } catch {
return null; return null;
@ -202,4 +245,26 @@ export class Ark {
return false; return false;
} }
} }
public open_thread(id: string) {
return new WebviewWindow(`event-${id}`, {
title: "Thread",
url: `/events/${id}`,
width: 600,
height: 800,
hiddenTitle: true,
titleBarStyle: "overlay",
});
}
public open_profile(pubkey: string) {
return new WebviewWindow(`user-${pubkey}`, {
title: "Profile",
url: `/users/${pubkey}`,
width: 600,
height: 800,
hiddenTitle: true,
titleBarStyle: "overlay",
});
}
} }

View File

@ -2,9 +2,12 @@ import { ReplyIcon } 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";
export function NoteReply() { export function NoteReply() {
const ark = useArk();
const event = useNoteContext(); const event = useNoteContext();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -13,6 +16,7 @@ export function NoteReply() {
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => ark.open_thread(event.id)}
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-800 dark:text-neutral-200" className="group inline-flex h-7 w-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
> >
<ReplyIcon className="size-5 group-hover:text-blue-500" /> <ReplyIcon className="size-5 group-hover:text-blue-500" />

View File

@ -110,7 +110,7 @@ export function NoteChild({
</div> </div>
<User.Provider pubkey={data.pubkey}> <User.Provider pubkey={data.pubkey}>
<User.Root> <User.Root>
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" /> <User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight"> <div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<User.Name className="max-w-[10rem] truncate" /> <User.Name className="max-w-[10rem] truncate" />
<div className="font-normal text-neutral-700 dark:text-neutral-300"> <div className="font-normal text-neutral-700 dark:text-neutral-300">

View File

@ -32,10 +32,7 @@ export function NoteMenu() {
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button <button type="button">
type="button"
className="inline-flex size-6 items-center justify-center"
>
<HorizontalDotsIcon className="size-4 hover:text-blue-500 dark:text-neutral-200" /> <HorizontalDotsIcon className="size-4 hover:text-blue-500 dark:text-neutral-200" />
</button> </button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>

View File

@ -73,17 +73,17 @@ export function RepostNote({
return ( return (
<Note.Root <Note.Root
className={cn( className={cn(
"flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950", "mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
className, className,
)} )}
> >
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
<User.Root className="flex h-14 gap-2 px-3"> <User.Root className="flex gap-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center"> <div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" /> <RepostIcon className="h-5 w-5 text-blue-500" />
</div> </div>
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" /> <User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex items-baseline gap-1"> <div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" /> <User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span> <span className="text-blue-500">{t("note.reposted")}</span>
@ -92,18 +92,23 @@ export function RepostNote({
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<Note.Provider event={repostEvent}> <Note.Provider event={repostEvent}>
<div className="relative flex flex-col gap-2 px-3"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-start justify-between">
<Note.User className="flex-1 pr-2" /> <Note.User className="flex-1 pr-2" />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Content /> <div className="flex gap-3">
<div className="flex h-14 items-center justify-between"> <div className="size-10 shrink-0" />
<Note.Pin /> <div className="min-w-0 flex-1">
<div className="inline-flex items-center gap-4"> <Note.Content />
<Note.Reply /> <div className="mt-5 flex items-center justify-between">
<Note.Repost /> <Note.Reaction />
<Note.Zap /> <div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -24,8 +24,8 @@ export function TextNote({
<div className="flex gap-3"> <div className="flex gap-3">
<div className="size-10 shrink-0" /> <div className="size-10 shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Note.Content className="mb-2" />
<Note.Thread className="mb-2" /> <Note.Thread className="mb-2" />
<Note.Content />
<div className="mt-5 flex items-center justify-between"> <div className="mt-5 flex items-center justify-between">
<Note.Reaction /> <Note.Reaction />
<div className="inline-flex items-center gap-4"> <div className="inline-flex items-center gap-4">

View File

@ -20,12 +20,12 @@ export function NoteThread({ className }: { className?: string }) {
return ( return (
<div className={cn("w-full", className)}> <div className={cn("w-full", className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900"> <div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
{thread.rootEventId ? ( {thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot /> <Note.Child eventId={thread.rootEventId} isRoot />
) : null} ) : null}
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
<div className="inline-flex items-center justify-between"> <div className="inline-flex items-center justify-between">
<a <a
href={`/events/${thread?.rootEventId || thread?.replyEventId}`} href={`/events/${thread?.rootEventId || thread?.replyEventId}`}

View File

@ -2,22 +2,22 @@ import { cn } from "@lume/utils";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
export function UserName({ className }: { className?: string }) { export function UserName({ className }: { className?: string }) {
const user = useUserContext(); const user = useUserContext();
if (!user.profile) { if (!user.profile) {
return ( return (
<div <div
className={cn( className={cn(
"h-4 w-20 self-center bg-black/20 dark:bg-white/20 rounded animate-pulse", "mb-1 h-3 w-20 animate-pulse self-center rounded bg-black/20 dark:bg-white/20",
className, className,
)} )}
/> />
); );
} }
return ( return (
<div className={cn("max-w-[12rem] truncate", className)}> <div className={cn("max-w-[12rem] truncate", className)}>
{user.profile.display_name || user.profile.name || "Anon"} {user.profile.display_name || user.profile.name || "Anon"}
</div> </div>
); );
} }

View File

@ -13,7 +13,6 @@ export function UserNip05({ className }: { className?: string }) {
queryFn: async () => { queryFn: async () => {
if (!user.profile?.nip05) return false; if (!user.profile?.nip05) return false;
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05); const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
console.log(verify);
return verify; return verify;
}, },
enabled: !!user.profile, enabled: !!user.profile,
@ -23,7 +22,7 @@ export function UserNip05({ className }: { className?: string }) {
return ( return (
<div <div
className={cn( className={cn(
"h-4 w-20 animate-pulse rounded bg-black/20 dark:bg-white/20", "h-3 w-20 animate-pulse rounded bg-black/20 dark:bg-white/20",
className, className,
)} )}
/> />

View File

@ -2,10 +2,13 @@ import { cn, formatCreatedAt } from "@lume/utils";
import { useMemo } from "react"; import { useMemo } from "react";
export function UserTime({ export function UserTime({
time, time,
className, className,
}: { time: number; className?: string }) { }: {
const createdAt = useMemo(() => formatCreatedAt(time), [time]); time: number;
className?: string;
}) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
return <div className={cn("", className)}>{createdAt}</div>; return <div className={cn("leading-tight", className)}>{createdAt}</div>;
} }

View File

@ -1,46 +1,49 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability", "identifier": "desktop-capability",
"description": "Capability for the desktop", "description": "Capability for the desktop",
"platforms": ["linux", "macOS", "windows"], "platforms": ["linux", "macOS", "windows"],
"windows": ["main", "settings", "event-*", "user-*", "column-*"], "windows": ["main", "settings", "event-*", "user-*", "column-*"],
"permissions": [ "permissions": [
"path:default", "path:default",
"event:default", "event:default",
"window:default", "window:default",
"app:default", "app:default",
"resources:default", "resources:default",
"menu:default", "menu:default",
"tray:default", "tray:default",
"theme:allow-set-theme", "theme:allow-set-theme",
"theme:allow-get-theme", "theme:allow-get-theme",
"notification:allow-is-permission-granted", "notification:allow-is-permission-granted",
"notification:allow-request-permission", "notification:allow-request-permission",
"notification:default", "notification:default",
"os:allow-locale", "os:allow-locale",
"os:allow-platform", "os:allow-platform",
"updater:allow-check", "updater:allow-check",
"updater:default", "updater:default",
"window:allow-start-dragging", "window:allow-start-dragging",
"store:allow-get", "store:allow-get",
{ "clipboard-manager:allow-write",
"identifier": "http:default", "clipboard-manager:allow-read",
"allow": [ "webview:allow-create-webview-window",
{ {
"url": "http://**/" "identifier": "http:default",
}, "allow": [
{ {
"url": "https://**/" "url": "http://**/"
} },
] {
}, "url": "https://**/"
{ }
"identifier": "fs:allow-read-text-file", ]
"allow": [ },
{ {
"path": "$RESOURCE/locales/*" "identifier": "fs:allow-read-text-file",
} "allow": [
] {
} "path": "$RESOURCE/locales/*"
] }
]
}
]
} }

View File

@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","context":"local","windows":["main","settings","event-*","user-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","theme:allow-set-theme","theme:allow-get-theme","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:allow-check","updater:default","window:allow-start-dragging","store:allow-get",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}} {"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","context":"local","windows":["main","settings","event-*","user-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","theme:allow-set-theme","theme:allow-get-theme","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:allow-check","updater:default","window:allow-start-dragging","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}}

View File

@ -6,15 +6,14 @@ use tauri::State;
#[tauri::command(async)] #[tauri::command(async)]
pub async fn get_event(id: &str, nostr: State<'_, Nostr>) -> Result<String, String> { pub async fn get_event(id: &str, nostr: State<'_, Nostr>) -> Result<String, String> {
let client = &nostr.client; let client = &nostr.client;
let event_id; let event_id: EventId = match Nip19::from_bech32(id) {
Ok(val) => match val {
if id.starts_with("note") { Nip19::EventId(id) => id,
event_id = EventId::from_bech32(id).unwrap(); Nip19::Event(event) => event.event_id,
} else if id.starts_with("nevent") { _ => panic!("not nip19"),
event_id = Nip19Event::from_bech32(id).unwrap().event_id; },
} else { Err(_) => EventId::from_hex(id).unwrap(),
event_id = EventId::from_hex(id).unwrap(); };
}
let filter = Filter::new().id(event_id); let filter = Filter::new().id(event_id);
let events = client let events = client

View File

@ -6,15 +6,14 @@ use tauri::State;
#[tauri::command(async)] #[tauri::command(async)]
pub async fn get_profile(id: &str, nostr: State<'_, Nostr>) -> Result<Metadata, ()> { pub async fn get_profile(id: &str, nostr: State<'_, Nostr>) -> Result<Metadata, ()> {
let client = &nostr.client; let client = &nostr.client;
let public_key; let public_key: XOnlyPublicKey = match Nip19::from_bech32(id) {
Ok(val) => match val {
if id.starts_with("nprofile1") { Nip19::Pubkey(pubkey) => pubkey,
public_key = XOnlyPublicKey::from_bech32(id).unwrap(); Nip19::Profile(profile) => profile.public_key,
} else if id.starts_with("npub1") { _ => panic!("not nip19"),
public_key = XOnlyPublicKey::from_bech32(id).unwrap(); },
} else { Err(_) => XOnlyPublicKey::from_str(id).unwrap(),
public_key = XOnlyPublicKey::from_str(id).unwrap(); };
}
let filter = Filter::new() let filter = Filter::new()
.author(public_key) .author(public_key)