feat: readd for you column

This commit is contained in:
2024-04-04 13:47:15 +07:00
parent 174b28f1a7
commit 999073f84c
34 changed files with 984 additions and 647 deletions

View File

@@ -45,15 +45,15 @@
"@lume/tsconfig": "workspace:^", "@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.26.7", "@tanstack/router-devtools": "^1.26.7",
"@tanstack/router-vite-plugin": "^1.26.6", "@tanstack/router-vite-plugin": "^1.26.8",
"@types/react": "^18.2.74", "@types/react": "^18.2.74",
"@types/react-dom": "^18.2.23", "@types/react-dom": "^18.2.24",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.6.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.3", "typescript": "^5.4.3",
"vite": "^5.2.7", "vite": "^5.2.8",
"vite-plugin-top-level-await": "^1.4.1", "vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -28,7 +28,7 @@ const persister = createSyncStoragePersister({
const ark = new Ark(); const ark = new Ark();
const platformName = await platform(); const platformName = await platform();
const osLocale = (await locale()).slice(0, 2); const osLocale = await locale();
// Set up a Router instance // Set up a Router instance
const router = createRouter({ const router = createRouter({
@@ -37,6 +37,8 @@ const router = createRouter({
platform: platformName, platform: platformName,
locale: osLocale, locale: osLocale,
settings: null, settings: null,
accounts: null,
interests: null,
ark, ark,
queryClient, queryClient,
}, },

View File

@@ -19,7 +19,7 @@ const DEFAULT_COLUMNS: LumeColumn[] = [
]; ];
function Screen() { function Screen() {
const search = Route.useSearch(); const { account } = Route.useParams();
const vlistRef = useRef<VListHandle>(null); const vlistRef = useRef<VListHandle>(null);
const [columns, setColumns] = useState(DEFAULT_COLUMNS); const [columns, setColumns] = useState(DEFAULT_COLUMNS);
@@ -139,8 +139,7 @@ function Screen() {
<Col <Col
key={column.id} key={column.id}
column={column} column={column}
// @ts-ignore, yolo !!! account={account}
account={search.acccount}
isScroll={isScroll} isScroll={isScroll}
/> />
))} ))}

View File

@@ -5,6 +5,16 @@ import { Accounts } from "@/components/accounts";
export const Route = createFileRoute("/$account")({ export const Route = createFileRoute("/$account")({
component: App, component: App,
beforeLoad: async ({ params, context }) => {
const ark = context.ark;
const settings = await ark.get_settings(params.account);
const interests = await ark.get_interest(params.account);
return {
settings,
interests,
};
},
}); });
function App() { function App() {

View File

@@ -7,7 +7,7 @@ import {
import { type Ark } from "@lume/ark"; import { type Ark } from "@lume/ark";
import { type QueryClient } from "@tanstack/react-query"; import { type QueryClient } from "@tanstack/react-query";
import { type Platform } from "@tauri-apps/plugin-os"; import { type Platform } from "@tauri-apps/plugin-os";
import { Settings } from "@lume/types"; import { Account, Interests, Settings } from "@lume/types";
interface RouterContext { interface RouterContext {
ark: Ark; ark: Ark;
@@ -15,6 +15,8 @@ interface RouterContext {
platform: Platform; platform: Platform;
locale: string; locale: string;
settings: Settings; settings: Settings;
interests: Interests;
accounts: Account[];
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({

View File

@@ -221,12 +221,7 @@ function Screen() {
</div> </div>
<div className="flex h-full min-h-0 w-full"> <div className="flex h-full min-h-0 w-full">
<div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2"> <div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2">
{reply_to && !quote ? ( {reply_to && !quote ? <MentionNote eventId={reply_to} /> : null}
<div className="flex flex-col 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"> <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)}
@@ -235,7 +230,9 @@ function Screen() {
autoCorrect="none" autoCorrect="none"
spellCheck={false} spellCheck={false}
renderElement={(props) => <Element {...props} />} renderElement={(props) => <Element {...props} />}
placeholder={t("editor.placeholder")} placeholder={
reply_to ? "Type your reply..." : t("editor.placeholder")
}
className="focus:outline-none" className="focus:outline-none"
/> />
{target && filters.length > 0 && ( {target && filters.length > 0 && (

View File

@@ -2,9 +2,9 @@ import { useEvent } from "@lume/ark";
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { Box, Container, Note, User } from "@lume/ui"; import { Box, Container, Note, User } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList"; import { ReplyList } from "./-components/replyList";
import { Event } from "@lume/types"; import { WindowVirtualizer } from "virtua";
import { type Event } from "@lume/types";
export const Route = createLazyFileRoute("/events/$eventId")({ export const Route = createLazyFileRoute("/events/$eventId")({
component: Event, component: Event,
@@ -29,14 +29,14 @@ function Event() {
} }
return ( return (
<WindowVirtualizer>
<Container withDrag> <Container withDrag>
<Box className="px-3 pt-3"> <Box className="px-3 pt-3 scrollbar-none">
<WindowVirtualizer>
<MainNote data={data} /> <MainNote data={data} />
{data ? <ReplyList eventId={eventId} /> : null} {data ? <ReplyList eventId={eventId} /> : null}
</WindowVirtualizer>
</Box> </Box>
</Container> </Container>
</WindowVirtualizer>
); );
} }

View File

@@ -1,30 +1,60 @@
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest"; import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { useEvents } from "@lume/ark";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons"; import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { Event, Kind } from "@lume/types";
import { Column } from "@lume/ui"; import { Column } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/foryou")({ export const Route = createFileRoute("/foryou")({
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
// @ts-ignore, useless !!!
const interests = await ark.get_interest(search.account);
if (!interests) {
throw redirect({
to: "/interests",
replace: false,
search,
});
}
return {
interests,
};
},
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
// @ts-ignore, just work!!! // @ts-ignore, just work!!!
const { id, name, account } = Route.useSearch(); const { id, name, account } = Route.useSearch();
const { ark, interests } = Route.useRouteContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
data, useInfiniteQuery({
hasNextPage, queryKey: ["foryou", account],
isLoading, initialPageParam: 0,
isRefetching, queryFn: async ({ pageParam }: { pageParam: number }) => {
isFetchingNextPage, const events = await ark.get_events_from_interests(
fetchNextPage, interests.hashtags,
} = useEvents("local", account); 20,
pageParam,
true,
);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
@@ -40,17 +70,23 @@ export function Screen() {
<Column.Root> <Column.Root>
<Column.Header id={id} name={name} /> <Column.Header id={id} name={name} />
<Column.Content> <Column.Content>
{isLoading || isRefetching ? ( {isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button type="button" className="size-5" disabled>
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
</button>
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950"> <div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-5 dark:bg-neutral-900">
<InfoIcon className="size-6" /> <InfoIcon className="size-6" />
<div> <div>
<p className="leading-tight">{t("emptyFeedTitle")}</p> <p className="font-medium leading-tight">
<p className="leading-tight">{t("emptyFeedSubtitle")}</p> {t("global.emptyFeedTitle")}
</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
{t("global.emptyFeedSubtitle")}
</p>
</div> </div>
</div> </div>
<Suggest /> <Suggest />
@@ -60,12 +96,13 @@ export function Screen() {
{data.map((item) => renderItem(item))} {data.map((item) => renderItem(item))}
</Virtualizer> </Virtualizer>
)} )}
{data?.length && hasNextPage ? (
<div className="flex h-20 items-center justify-center"> <div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button <button
type="button" type="button"
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage} disabled={isFetchingNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800" className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
> >
{isFetchingNextPage ? ( {isFetchingNextPage ? (
@@ -77,8 +114,8 @@ export function Screen() {
</> </>
)} )}
</button> </button>
) : null}
</div> </div>
) : null}
</Column.Content> </Column.Content>
</Column.Root> </Column.Root>
); );

View File

@@ -1,10 +1,10 @@
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest"; import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { useEvents } from "@lume/ark";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons"; import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { Event, Kind } from "@lume/types";
import { Column } from "@lume/ui"; import { Column } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
@@ -16,15 +16,23 @@ export const Route = createLazyFileRoute("/global")({
export function Screen() { export function Screen() {
// @ts-ignore, just work!!! // @ts-ignore, just work!!!
const { id, name, account } = Route.useSearch(); const { id, name, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
data, useInfiniteQuery({
hasNextPage, queryKey: ["global", account],
isLoading, initialPageParam: 0,
isRefetching, queryFn: async ({ pageParam }: { pageParam: number }) => {
isFetchingNextPage, const events = await ark.get_events(20, pageParam, undefined, true);
fetchNextPage, return events;
} = useEvents("global", account); },
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
@@ -40,17 +48,23 @@ export function Screen() {
<Column.Root> <Column.Root>
<Column.Header id={id} name={name} /> <Column.Header id={id} name={name} />
<Column.Content> <Column.Content>
{isLoading || isRefetching ? ( {isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button type="button" className="size-5" disabled>
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
</button>
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950"> <div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-5 dark:bg-neutral-900">
<InfoIcon className="size-6" /> <InfoIcon className="size-6" />
<div> <div>
<p className="leading-tight">{t("emptyFeedTitle")}</p> <p className="font-medium leading-tight">
<p className="leading-tight">{t("emptyFeedSubtitle")}</p> {t("global.emptyFeedTitle")}
</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
{t("global.emptyFeedSubtitle")}
</p>
</div> </div>
</div> </div>
<Suggest /> <Suggest />
@@ -60,12 +74,13 @@ export function Screen() {
{data.map((item) => renderItem(item))} {data.map((item) => renderItem(item))}
</Virtualizer> </Virtualizer>
)} )}
{data?.length && hasNextPage ? (
<div className="flex h-20 items-center justify-center"> <div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button <button
type="button" type="button"
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage} disabled={isFetchingNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800" className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
> >
{isFetchingNextPage ? ( {isFetchingNextPage ? (
@@ -77,8 +92,8 @@ export function Screen() {
</> </>
)} )}
</button> </button>
) : null}
</div> </div>
) : null}
</Column.Content> </Column.Content>
</Column.Root> </Column.Root>
); );

View File

@@ -5,7 +5,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ context }) => {
const ark = context.ark; const ark = context.ark;
const accounts = await ark.get_all_accounts(); const accounts = await ark.get_all_accounts();
@@ -18,15 +18,8 @@ export const Route = createFileRoute("/")({
}); });
// Only 1 account, skip account selection screen // Only 1 account, skip account selection screen
case 1: case 1:
// @ts-ignore, totally fine !!!
if (search.manually) return;
const account = accounts[0].npub; const account = accounts[0].npub;
const loadedAccount = await ark.load_selected_account(account); const loadedAccount = await ark.load_selected_account(account);
const settings = await ark.get_settings(account);
// Update settings
context.settings = settings;
if (loadedAccount) { if (loadedAccount) {
throw redirect({ throw redirect({
@@ -37,7 +30,7 @@ export const Route = createFileRoute("/")({
} }
// Account selection // Account selection
default: default:
return; return { accounts };
} }
}, },
component: Screen, component: Screen,
@@ -51,11 +44,12 @@ function Screen() {
const select = async (npub: string) => { const select = async (npub: string) => {
setLoading(true); setLoading(true);
const loadAccount = await context.ark.load_selected_account(npub);
context.settings = await context.ark.get_settings(npub); const ark = context.ark;
const loadAccount = await ark.load_selected_account(npub);
if (loadAccount) { if (loadAccount) {
navigate({ return navigate({
to: "/$account/home", to: "/$account/home",
params: { account: npub }, params: { account: npub },
replace: true, replace: true,
@@ -83,7 +77,7 @@ function Screen() {
</div> </div>
) : ( ) : (
<> <>
{context.ark.accounts.map((account) => ( {context.accounts.map((account) => (
<button <button
type="button" type="button"
key={account.npub} key={account.npub}

View File

@@ -0,0 +1,115 @@
import { Column } from "@lume/ui";
import { TOPICS, cn } from "@lume/utils";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/interests")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
const [hashtags, setHashtags] = useState<string[]>([]);
const [isDone, setIsDone] = useState(false);
const context = Route.useRouteContext();
const search = Route.useSearch();
const toggleHashtag = (item: string) => {
const arr = hashtags.includes(item)
? hashtags.filter((i) => i !== item)
: [...hashtags, item];
setHashtags(arr);
};
const toggleAll = (item: string[]) => {
const sets = new Set([...hashtags, ...item]);
setHashtags([...sets]);
};
const submit = async () => {
try {
if (isDone) {
return history.back();
}
const ark = context.ark;
const eventId = await ark.set_interest(undefined, undefined, hashtags);
if (eventId) {
setIsDone(true);
toast.success("Interest has been updated successfully.");
}
} catch (e) {
toast.error(String(e));
}
};
return (
<Column.Root>
<Column.Header id={search.id} name={search.name} />
<Column.Content>
<div className="sticky left-0 top-0 flex h-16 w-full items-center justify-between border-b border-neutral-100 bg-white px-3 dark:border-neutral-900 dark:bg-black">
<div className="flex flex-1 flex-col">
<h3 className="font-semibold">Interests</h3>
<p className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Pick things you'd like to see.
</p>
</div>
<button
type="button"
onClick={submit}
className="inline-flex h-8 w-20 items-center justify-center rounded-full bg-blue-500 px-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50"
>
{isDone ? t("global.back") : t("global.update")}
</button>
</div>
<div className="flex w-full flex-col p-3">
<div className="flex flex-col gap-8">
{TOPICS.map((topic) => (
<div key={topic.title} className="flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-2.5">
<img
src={topic.icon}
alt={topic.title}
className="size-8 rounded-lg object-cover"
/>
<h3 className="text-lg font-semibold">{topic.title}</h3>
</div>
<button
type="button"
onClick={() => toggleAll(topic.content)}
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
{t("interests.followAll")}
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
{topic.content.map((hashtag) => (
<button
key={hashtag}
type="button"
onClick={() => toggleHashtag(hashtag)}
className={cn(
"inline-flex items-center rounded-full border border-transparent bg-neutral-100 px-2 py-1 text-sm font-medium dark:bg-neutral-900",
hashtags.includes(hashtag)
? "border-blue-500 text-blue-500"
: "",
)}
>
{hashtag}
</button>
))}
</div>
</div>
))}
</div>
</div>
</Column.Content>
</Column.Root>
);
}

View File

@@ -23,13 +23,12 @@ export function Screen() {
queryKey: ["local", account], queryKey: ["local", account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events("local", 20, pageParam, true); const events = await ark.get_events(20, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1); const lastEvent = lastPage?.at(-1);
if (!lastEvent) return; return lastEvent ? lastEvent.created_at - 1 : null;
return lastEvent.created_at - 1;
}, },
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,

View File

@@ -1,4 +1,4 @@
import { ArrowRightIcon, ZapIcon } from "@lume/icons"; import { ZapIcon } from "@lume/icons";
import { Container } from "@lume/ui"; import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
@@ -15,14 +15,11 @@ function Screen() {
const save = async () => { const save = async () => {
const nwc = await ark.set_nwc(uri); const nwc = await ark.set_nwc(uri);
setIsDone(nwc);
if (nwc) {
setIsDone(true);
}
}; };
return ( return (
<Container withDrag> <Container withDrag withNavigate={false}>
<div className="h-full w-full flex-1 px-5"> <div className="h-full w-full flex-1 px-5">
{!isDone ? ( {!isDone ? (
<> <>
@@ -44,17 +41,15 @@ function Screen() {
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://" placeholder="nostrconnect://"
className="h-24 w-full resize-none rounded-lg border-transparent bg-white placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-black dark:focus:ring-blue-900" className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={save} onClick={save}
className="inline-flex h-11 w-full items-center justify-between gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600" className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
> >
<div className="size-5" /> Save & Connect
<div>Save & Connect</div>
<ArrowRightIcon className="size-5" />
</button> </button>
</div> </div>
</> </>

View File

@@ -1,6 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua"; import { WindowVirtualizer } from "virtua";
import { User } from "@lume/ui"; import { Box, Container, User } from "@lume/ui";
import { EventList } from "./-components/eventList"; import { EventList } from "./-components/eventList";
export const Route = createLazyFileRoute("/users/$pubkey")({ export const Route = createLazyFileRoute("/users/$pubkey")({
@@ -11,16 +11,13 @@ function Screen() {
const { pubkey } = Route.useParams(); const { pubkey } = Route.useParams();
return ( return (
<Container withDrag>
<Box className="px-0 scrollbar-none">
<WindowVirtualizer> <WindowVirtualizer>
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900">
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
<div className="flex h-full min-h-0 w-full">
<div className="h-full w-full flex-1 px-2 pb-2">
<div className="h-full w-full overflow-hidden overflow-y-auto rounded-xl bg-white 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">
<User.Provider pubkey={pubkey}> <User.Provider pubkey={pubkey}>
<User.Root> <User.Root>
<User.Cover className="h-44 w-full object-cover" /> <User.Cover className="h-44 w-full object-cover" />
<div className="relative -mt-8 flex flex-col gap-4 px-5"> <div className="relative -mt-8 flex flex-col gap-4 px-3">
<User.Avatar className="size-14 rounded-full" /> <User.Avatar className="size-14 rounded-full" />
<div className="inline-flex items-start justify-between"> <div className="inline-flex items-start justify-between">
<div> <div>
@@ -33,14 +30,14 @@ function Screen() {
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<div className="mt-4 px-5"> <div className="mt-4">
<h3 className="mb-4 text-lg font-semibold">Notes</h3> <div className="px-3">
<h3 className="text-lg font-semibold">Latest notes</h3>
</div>
<EventList id={pubkey} /> <EventList id={pubkey} />
</div> </div>
</div>
</div>
</div>
</div>
</WindowVirtualizer> </WindowVirtualizer>
</Box>
</Container>
); );
} }

View File

@@ -13,7 +13,7 @@
"@astrojs/check": "^0.5.10", "@astrojs/check": "^0.5.10",
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@fontsource/geist-mono": "^5.0.2", "@fontsource/geist-mono": "^5.0.2",
"astro": "^4.5.14", "astro": "^4.5.15",
"astro-seo-meta": "^4.1.0", "astro-seo-meta": "^4.1.0",
"astro-seo-schema": "^4.0.0", "astro-seo-schema": "^4.0.0",
"schema-dts": "^1.1.2", "schema-dts": "^1.1.2",

View File

@@ -11,8 +11,8 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.6.3", "@biomejs/biome": "^1.6.4",
"@tauri-apps/cli": "2.0.0-beta.9", "@tauri-apps/cli": "2.0.0-beta.12",
"turbo": "^1.13.2" "turbo": "^1.13.2"
}, },
"packageManager": "pnpm@8.9.0", "packageManager": "pnpm@8.9.0",
@@ -20,7 +20,7 @@
"node": ">=18" "node": ">=18"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "2.0.0-beta.5", "@tauri-apps/api": "2.0.0-beta.7",
"@tauri-apps/plugin-autostart": "2.0.0-beta.2", "@tauri-apps/plugin-autostart": "2.0.0-beta.2",
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.0", "@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.0",
"@tauri-apps/plugin-dialog": "2.0.0-beta.2", "@tauri-apps/plugin-dialog": "2.0.0-beta.2",
@@ -30,7 +30,6 @@
"@tauri-apps/plugin-os": "2.0.0-beta.2", "@tauri-apps/plugin-os": "2.0.0-beta.2",
"@tauri-apps/plugin-process": "2.0.0-beta.2", "@tauri-apps/plugin-process": "2.0.0-beta.2",
"@tauri-apps/plugin-shell": "2.0.0-beta.2", "@tauri-apps/plugin-shell": "2.0.0-beta.2",
"@tauri-apps/plugin-sql": "2.0.0-beta.2",
"@tauri-apps/plugin-updater": "2.0.0-beta.2", "@tauri-apps/plugin-updater": "2.0.0-beta.2",
"@tauri-apps/plugin-upload": "2.0.0-beta.2" "@tauri-apps/plugin-upload": "2.0.0-beta.2"
} }

View File

@@ -4,6 +4,7 @@ import type {
Contact, Contact,
Event, Event,
EventWithReplies, EventWithReplies,
Interests,
Keys, Keys,
Metadata, Metadata,
Settings, Settings,
@@ -14,10 +15,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 accounts: Account[]; public windows: WebviewWindow[];
constructor() { constructor() {
this.accounts = []; this.windows = [];
} }
public async get_all_accounts() { public async get_all_accounts() {
@@ -29,8 +30,6 @@ export class Ark {
for (const item of cmd) { for (const item of cmd) {
accounts.push({ npub: item.replace(".npub", "") }); accounts.push({ npub: item.replace(".npub", "") });
} }
this.accounts = accounts;
return accounts; return accounts;
} }
} catch { } catch {
@@ -127,21 +126,24 @@ export class Ark {
} }
public async get_events( public async get_events(
type: "local" | "global",
limit: number, limit: number,
asOf?: number, asOf?: number,
dedup?: boolean, contacts?: string[],
global?: 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 dedup = true;
const seenIds = new Set<string>(); const seenIds = new Set<string>();
const dedupQueue = new Set<string>(); const dedupQueue = new Set<string>();
const nostrEvents: Event[] = await invoke(`get_${type}_events`, { const nostrEvents: Event[] = await invoke("get_events", {
limit, limit,
until, until,
contacts,
global,
}); });
if (dedup) { if (dedup) {
@@ -156,7 +158,6 @@ export class Ark {
dedupQueue.add(event.id); dedupQueue.add(event.id);
break; break;
} }
seenIds.add(tag); seenIds.add(tag);
} }
} }
@@ -173,6 +174,51 @@ export class Ark {
} }
} }
public async get_events_from_interests(
hashtags: string[],
limit: number,
asOf?: number,
global?: boolean,
) {
let until: string = undefined;
if (asOf && asOf > 0) until = asOf.toString();
const dedup = true;
const seenIds = new Set<string>();
const dedupQueue = new Set<string>();
const nostrEvents: Event[] = await invoke("get_events_from_interests", {
hashtags,
limit,
until,
global,
});
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);
}
public async publish(content: string, reply_to?: string, quote?: boolean) { public async publish(content: string, reply_to?: string, quote?: boolean) {
try { try {
const g = await generateContentTags(content); const g = await generateContentTags(content);
@@ -548,32 +594,80 @@ export class Ark {
} }
} }
public async get_interest(id: string) {
try {
const cmd: string = await invoke("get_interest", { id });
if (!cmd) return null;
if (!cmd.length) return null;
const interests: Interests = JSON.parse(cmd);
return interests;
} catch {
return null;
}
}
public async set_interest(
words: string[],
users: string[],
hashtags: string[],
) {
try {
const interests: Interests = {
words: words ?? [],
users: users ?? [],
hashtags: hashtags ?? [],
};
const cmd: string = await invoke("set_interest", {
content: JSON.stringify(interests),
});
return cmd;
} catch {
return null;
}
}
public open_thread(id: string) { public open_thread(id: string) {
return new WebviewWindow(`event-${id}`, { try {
const window = new WebviewWindow(`event-${id}`, {
title: "Thread", title: "Thread",
url: `/events/${id}`, url: `/events/${id}`,
minWidth: 500, minWidth: 500,
minHeight: 800,
width: 500, width: 500,
height: 800, height: 800,
hiddenTitle: true, hiddenTitle: true,
titleBarStyle: "overlay", titleBarStyle: "overlay",
center: false, center: false,
}); });
this.windows.push(window);
} catch (e) {
throw new Error(String(e));
}
} }
public open_profile(pubkey: string) { public open_profile(pubkey: string) {
return new WebviewWindow(`user-${pubkey}`, { try {
const window = new WebviewWindow(`user-${pubkey}`, {
title: "Profile", title: "Profile",
url: `/users/${pubkey}`, url: `/users/${pubkey}`,
minWidth: 500, minWidth: 500,
minHeight: 800,
width: 500, width: 500,
height: 800, height: 800,
hiddenTitle: true, hiddenTitle: true,
titleBarStyle: "overlay", titleBarStyle: "overlay",
}); });
this.windows.push(window);
} catch (e) {
throw new Error(String(e));
}
} }
public open_editor(reply_to?: string, quote: boolean = false) { public open_editor(reply_to?: string, quote: boolean = false) {
try {
let url: string; let url: string;
if (reply_to) { if (reply_to) {
@@ -582,7 +676,7 @@ export class Ark {
url = "/editor"; url = "/editor";
} }
return new WebviewWindow("editor", { const window = new WebviewWindow(`editor-${reply_to ? reply_to : 0}`, {
title: "Editor", title: "Editor",
url, url,
minWidth: 500, minWidth: 500,
@@ -591,46 +685,68 @@ export class Ark {
height: 400, height: 400,
hiddenTitle: true, hiddenTitle: true,
titleBarStyle: "overlay", titleBarStyle: "overlay",
fileDropEnabled: true,
}); });
this.windows.push(window);
} catch (e) {
throw new Error(String(e));
}
} }
public open_nwc() { public open_nwc() {
return new WebviewWindow("nwc", { try {
const window = new WebviewWindow("nwc", {
title: "Nostr Wallet Connect", title: "Nostr Wallet Connect",
url: "/nwc", url: "/nwc",
minWidth: 400, minWidth: 400,
minHeight: 600,
width: 400, width: 400,
height: 600, height: 600,
hiddenTitle: true, hiddenTitle: true,
titleBarStyle: "overlay", titleBarStyle: "overlay",
fileDropEnabled: true,
}); });
this.windows.push(window);
} catch (e) {
throw new Error(String(e));
}
} }
public open_zap(id: string, pubkey: string, account: string) { public open_zap(id: string, pubkey: string, account: string) {
return new WebviewWindow(`zap-${id}`, { try {
title: "Nostr Wallet Connect", const window = new WebviewWindow(`zap-${id}`, {
title: "Zap",
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`, url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
minWidth: 400, minWidth: 400,
minHeight: 500,
width: 400, width: 400,
height: 500, height: 500,
hiddenTitle: true, hiddenTitle: true,
titleBarStyle: "overlay", titleBarStyle: "overlay",
fileDropEnabled: true,
}); });
this.windows.push(window);
} catch (e) {
throw new Error(String(e));
}
} }
public open_settings() { public open_settings() {
return new WebviewWindow("settings", { try {
const window = new WebviewWindow("settings", {
title: "Settings", title: "Settings",
url: "/settings", url: "/settings",
minWidth: 600, minWidth: 600,
minHeight: 500,
width: 800, width: 800,
height: 500, height: 500,
hiddenTitle: true, hiddenTitle: true,
titleBarStyle: "overlay", titleBarStyle: "overlay",
fileDropEnabled: true,
}); });
this.windows.push(window);
} catch (e) {
throw new Error(String(e));
}
} }
} }

View File

@@ -9,7 +9,12 @@ export function ColumnContent({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("flex-1 overflow-y-auto overflow-x-hidden", className)}> <div
className={cn(
"flex-1 overflow-y-auto overflow-x-hidden scrollbar-none",
className,
)}
>
{children} {children}
</div> </div>
); );

View File

@@ -34,6 +34,7 @@ export function NoteUser({ className }: { className?: string }) {
<HoverCard.Content <HoverCard.Content
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none" className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
sideOffset={5} sideOffset={5}
side="right"
> >
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<User.Avatar className="size-11 rounded-lg object-cover" /> <User.Avatar className="size-11 rounded-lg object-cover" />

View File

@@ -22,7 +22,7 @@
"@lume/tsconfig": "workspace:^", "@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@types/react": "^18.2.74", "@types/react": "^18.2.74",
"@types/react-dom": "^18.2.23", "@types/react-dom": "^18.2.24",
"tailwind-merge": "^2.2.2", "tailwind-merge": "^2.2.2",
"typescript": "^5.4.3" "typescript": "^5.4.3"
} }

796
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,12 @@
"windows": [ "windows": [
"main", "main",
"splash", "splash",
"editor",
"settings", "settings",
"nwc", "nwc",
"zap-*", "zap-*",
"event-*", "event-*",
"user-*", "user-*",
"editor-*",
"column-*" "column-*"
], ],
"permissions": [ "permissions": [

View File

@@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","splash","editor","settings","nwc","zap-*","event-*","user-*","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","updater:allow-check","updater:default","window:allow-start-dragging","window:allow-create","window:allow-close","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","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","fs:allow-read-file","shell:allow-open",{"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","local":true,"windows":["main","splash","settings","nwc","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","updater:allow-check","updater:default","window:allow-start-dragging","window:allow-create","window:allow-close","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","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","fs:allow-read-file","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}}

View File

@@ -126,8 +126,8 @@ fn main() {
nostr::metadata::zap_event, nostr::metadata::zap_event,
nostr::event::get_event, nostr::event::get_event,
nostr::event::get_events_from, nostr::event::get_events_from,
nostr::event::get_local_events, nostr::event::get_events,
nostr::event::get_global_events, nostr::event::get_events_from_interests,
nostr::event::get_event_thread, nostr::event::get_event_thread,
nostr::event::publish, nostr::event::publish,
nostr::event::repost, nostr::event::repost,

View File

@@ -72,66 +72,120 @@ pub async fn get_events_from(
} }
#[tauri::command] #[tauri::command]
pub async fn get_local_events( pub async fn get_events(
limit: usize, limit: usize,
until: Option<&str>, until: Option<&str>,
contacts: Option<Vec<&str>>,
global: Option<bool>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<Vec<Event>, String> { ) -> Result<Vec<Event>, String> {
let client = &state.client; let client = &state.client;
let f_until = match until { let as_of = match until {
Some(until) => Timestamp::from_str(until).unwrap(), Some(until) => Timestamp::from_str(until).unwrap(),
None => Timestamp::now(), None => Timestamp::now(),
}; };
let authors = match contacts {
let contact_list = client Some(val) => {
.get_contact_list_public_keys(Some(Duration::from_secs(10))) let c: Vec<PublicKey> = val
.await; .into_iter()
.map(|key| PublicKey::from_str(key).unwrap())
if let Ok(authors) = contact_list { .collect();
if authors.len() == 0 { Some(c)
return Err("Get text event failed".into());
} }
None => match global {
let filter = Filter::new() Some(val) => match val {
.kinds(vec![Kind::TextNote, Kind::Repost]) true => None,
.authors(authors) false => {
.limit(limit) match client
.until(f_until); .get_contact_list_public_keys(Some(Duration::from_secs(10)))
if let Ok(events) = client
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
.await .await
{ {
Ok(val) => Some(val),
Err(_) => None,
}
}
},
None => {
match client
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
.await
{
Ok(val) => Some(val),
Err(_) => None,
}
}
},
};
let filter = match authors {
Some(val) => Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(val)
.limit(limit)
.until(as_of),
None => Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(limit)
.until(as_of),
};
if let Ok(events) = client
.get_events_of(vec![filter], Some(Duration::from_secs(15)))
.await
{
println!("total events: {}", events.len());
Ok(events) Ok(events)
} else { } else {
Err("Get text event failed".into()) Err("Get text event failed".into())
} }
} else {
Err("Get contact list failed".into())
}
} }
#[tauri::command] #[tauri::command]
pub async fn get_global_events( pub async fn get_events_from_interests(
hashtags: Vec<&str>,
limit: usize, limit: usize,
until: Option<&str>, until: Option<&str>,
global: Option<bool>,
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<Vec<Event>, String> { ) -> Result<Vec<Event>, String> {
let client = &state.client; let client = &state.client;
let f_until = match until { let as_of = match until {
Some(until) => Timestamp::from_str(until).unwrap(), Some(until) => Timestamp::from_str(until).unwrap(),
None => Timestamp::now(), None => Timestamp::now(),
}; };
let authors = match global {
let filter = Filter::new() Some(val) => match val {
.kinds(vec![Kind::TextNote, Kind::Repost]) true => None,
.limit(limit) false => {
.until(f_until); match client
.get_contact_list_public_keys(Some(Duration::from_secs(10)))
if let Ok(events) = client
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
.await .await
{ {
Ok(val) => Some(val),
Err(_) => None,
}
}
},
None => None,
};
let filter = match authors {
Some(val) => Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(val)
.limit(limit)
.until(as_of)
.hashtags(hashtags),
None => Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(limit)
.until(as_of)
.hashtags(hashtags),
};
if let Ok(events) = client
.get_events_of(vec![filter], Some(Duration::from_secs(15)))
.await
{
println!("total events: {}", events.len());
Ok(events) Ok(events)
} else { } else {
Err("Get text event failed".into()) Err("Get text event failed".into())

View File

@@ -44,7 +44,16 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
} }
} }
"editor" => { "editor" => {
let _ = WebviewWindowBuilder::new(app, "editor", WebviewUrl::App(PathBuf::from("editor"))) if let Some(window) = app.get_window("editor-0") {
if window.is_visible().unwrap_or_default() {
let _ = window.set_focus();
} else {
let _ = window.show();
let _ = window.set_focus();
};
} else {
let _ =
WebviewWindowBuilder::new(app, "editor-0", WebviewUrl::App(PathBuf::from("editor")))
.title("Editor") .title("Editor")
.min_inner_size(500., 400.) .min_inner_size(500., 400.)
.inner_size(600., 400.) .inner_size(600., 400.)
@@ -53,6 +62,7 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
.build() .build()
.unwrap(); .unwrap();
} }
}
"about" => { "about" => {
app.shell().open("https://lume.nu", None).unwrap(); app.shell().open("https://lume.nu", None).unwrap();
} }