wip: migrate frontend to new backend

This commit is contained in:
2024-02-09 15:33:27 +07:00
parent ec78cf8bf7
commit 739ba63e6c
55 changed files with 351 additions and 933 deletions

View File

@@ -19,11 +19,11 @@ export default function App() {
<I18nextProvider i18n={i18n} defaultNS={"translation"}> <I18nextProvider i18n={i18n} defaultNS={"translation"}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster position="top-center" theme="system" closeButton /> <Toaster position="top-center" theme="system" closeButton />
<StorageProvider> <ArkProvider>
<ArkProvider> <StorageProvider>
<Router /> <Router />
</ArkProvider> </StorageProvider>
</StorageProvider> </ArkProvider>
</QueryClientProvider> </QueryClientProvider>
</I18nextProvider> </I18nextProvider>
); );

View File

@@ -3,7 +3,7 @@ import { Timeline } from "@columns/timeline";
export function HomeScreen() { export function HomeScreen() {
return ( return (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Timeline column={{ id: 1, kind: 1, title: "", content: "" }} /> <Timeline column={{ id: 1, title: "", content: "" }} />
</div> </div>
); );
} }

View File

@@ -11,9 +11,11 @@ export class Ark {
public async verify_signer() { public async verify_signer() {
try { try {
const cmd: string = await invoke("verify_signer"); const cmd: string = await invoke("verify_signer");
if (!cmd) return false; if (cmd) {
this.account.pubkey = cmd; this.account.pubkey = cmd;
return true; return true;
}
return false;
} catch (e) { } catch (e) {
console.error(String(e)); console.error(String(e));
} }

View File

@@ -5,48 +5,33 @@ import {
RefreshIcon, RefreshIcon,
TrashIcon, TrashIcon,
} from "@lume/icons"; } from "@lume/icons";
import { useColumn } from "@lume/storage";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { InterestModal } from "./interestModal";
import { useColumnContext } from "./provider"; import { useColumnContext } from "./provider";
export function ColumnHeader({ export function ColumnHeader({
id,
title,
queryKey, queryKey,
}: { }: {
id: number;
title: string;
queryKey?: string[]; queryKey?: string[];
}) { }) {
const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const { moveColumn, removeColumn } = useColumnContext(); const { move, remove } = useColumn();
const column = useColumnContext();
const queryClient = useQueryClient();
const refresh = async () => { const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey }); if (queryKey) await queryClient.refetchQueries({ queryKey });
}; };
const moveLeft = async () => {
moveColumn(id, "left");
};
const moveRight = async () => {
moveColumn(id, "right");
};
const deleteWidget = async () => {
await removeColumn(id);
};
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root>
<div className="flex items-center justify-center gap-2 px-3 w-full border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900"> <div className="flex items-center justify-center gap-2 px-3 w-full border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900">
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{title}</div> <div className="text-[13px] font-medium">{column.title}</div>
<ChevronDownIcon className="size-5" /> <ChevronDownIcon className="size-5" />
</div> </div>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -65,18 +50,10 @@ export function ColumnHeader({
{t("global.refresh")} {t("global.refresh")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
{queryKey?.[0] === "foryou-9998" ? (
<DropdownMenu.Item asChild>
<InterestModal
queryKey={queryKey}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
/>
</DropdownMenu.Item>
) : null}
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
type="button" type="button"
onClick={moveLeft} onClick={() => move(column.id, "left")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<MoveLeftIcon className="size-4" /> <MoveLeftIcon className="size-4" />
@@ -86,7 +63,7 @@ export function ColumnHeader({
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
type="button" type="button"
onClick={moveRight} onClick={() => move(column.id, "right")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<MoveRightIcon className="size-4" /> <MoveRightIcon className="size-4" />
@@ -97,7 +74,7 @@ export function ColumnHeader({
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
<button <button
type="button" type="button"
onClick={deleteWidget} onClick={() => remove(column.id)}
className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none" className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />

View File

@@ -2,9 +2,11 @@ import { Route } from "react-router-dom";
import { ColumnContent } from "./content"; import { ColumnContent } from "./content";
import { ColumnHeader } from "./header"; import { ColumnHeader } from "./header";
import { ColumnLiveWidget } from "./live"; import { ColumnLiveWidget } from "./live";
import { ColumnProvider } from "./provider";
import { ColumnRoot } from "./root"; import { ColumnRoot } from "./root";
export const Column = { export const Column = {
Provider: ColumnProvider,
Root: ColumnRoot, Root: ColumnRoot,
Live: ColumnLiveWidget, Live: ColumnLiveWidget,
Header: ColumnHeader, Header: ColumnHeader,

View File

@@ -1,7 +1,5 @@
import { ArrowUpIcon } from "@lume/icons"; import { ArrowUpIcon } from "@lume/icons";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useArk } from "../../hooks/useArk";
export function ColumnLiveWidget({ export function ColumnLiveWidget({
filter, filter,

View File

@@ -1,136 +1,14 @@
import { useStorage } from "@lume/storage"; import { LumeColumn } from "@lume/types";
import { IColumn } from "@lume/types"; import { ReactNode, createContext, useContext } from "react";
import { COL_TYPES } from "@lume/utils";
import {
type MutableRefObject,
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { type VListHandle } from "virtua";
type ColumnContext = { const ColumnContext = createContext<LumeColumn>(null);
columns: IColumn[];
vlistRef: MutableRefObject<VListHandle>;
addColumn: (column: IColumn) => Promise<void>;
removeColumn: (id: number) => Promise<void>;
moveColumn: (id: number, position: "left" | "right") => void;
updateColumn: (id: number, title: string, content: string) => Promise<void>;
loadAllColumns: () => Promise<IColumn[]>;
};
const ColumnContext = createContext<ColumnContext>(null);
export function ColumnProvider({ children }: { children: ReactNode }) {
const storage = useStorage();
const vlistRef = useRef<VListHandle>(null);
const [columns, setColumns] = useState<IColumn[]>([
{
id: 9999,
title: "Newsfeed",
content: "",
kind: COL_TYPES.newsfeed,
},
{
id: 9998,
title: "For You",
content: "",
kind: COL_TYPES.foryou,
},
]);
const loadAllColumns = useCallback(async () => {
return await storage.getColumns();
}, []);
const addColumn = useCallback(async (column: IColumn) => {
const result = await storage.createColumn(
column.kind,
column.title,
column.content,
);
if (result) {
setColumns((prev) => [...prev, result]);
vlistRef?.current.scrollToIndex(columns.length);
}
}, []);
const removeColumn = useCallback(async (id: number) => {
if (id === 9998 || id === 9999) {
toast.info("You cannot remove default column");
return;
}
await storage.removeColumn(id);
setColumns((prev) => prev.filter((t) => t.id !== id));
}, []);
const updateColumn = useCallback(
async (id: number, title: string, content: string) => {
const res = await storage.updateColumn(id, title, content);
if (res) {
const newCols = columns.map((col) => {
if (col.id === id) {
return { ...col, title, content };
}
return col;
});
setColumns(newCols);
}
},
[columns],
);
const moveColumn = useCallback(
(id: number, position: "left" | "right") => {
const newCols = [...columns];
const col = newCols.find((el) => el.id === id);
const colIndex = newCols.findIndex((el) => el.id === id);
newCols.splice(colIndex, 1);
if (position === "left") newCols.splice(colIndex - 1, 0, col);
if (position === "right") newCols.splice(colIndex + 1, 0, col);
setColumns(newCols);
},
[columns],
);
useEffect(() => {
let isMounted = true;
loadAllColumns().then((data) => {
if (isMounted) setColumns((prev) => [...prev, ...data]);
});
return () => {
isMounted = false;
};
}, []);
export function ColumnProvider({
column,
children,
}: { column: LumeColumn; children: ReactNode }) {
return ( return (
<ColumnContext.Provider <ColumnContext.Provider value={column}>{children}</ColumnContext.Provider>
value={{
columns,
vlistRef,
addColumn,
removeColumn,
moveColumn,
updateColumn,
loadAllColumns,
}}
>
{children}
</ColumnContext.Provider>
); );
} }

View File

@@ -1,6 +1,4 @@
import { NDKAppHandlerEvent } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useArk } from "../../hooks/useArk";
export function AppHandler({ tag }: { tag: string[] }) { export function AppHandler({ tag }: { tag: string[] }) {
const ark = useArk(); const ark = useArk();

View File

@@ -1,19 +1,7 @@
import { COL_TYPES } from "@lume/utils";
import { useColumnContext } from "../../column/provider";
export function Hashtag({ tag }: { tag: string }) { export function Hashtag({ tag }: { tag: string }) {
const { addColumn } = useColumnContext();
return ( return (
<button <button
type="button" type="button"
onClick={async () =>
await addColumn({
kind: COL_TYPES.hashtag,
title: tag,
content: tag,
})
}
className="text-blue-500 break-all cursor-default hover:text-blue-600" className="text-blue-500 break-all cursor-default hover:text-blue-600"
> >
{tag} {tag}

View File

@@ -1,11 +1,10 @@
import { PinIcon } from "@lume/icons"; import { PinIcon } from "@lume/icons";
import { COL_TYPES, NOSTR_MENTIONS } from "@lume/utils"; import { NOSTR_MENTIONS } from "@lume/utils";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { useEvent } from "../../../hooks/useEvent"; import { useEvent } from "../../../hooks/useEvent";
import { useColumnContext } from "../../column/provider";
import { User } from "../../user"; import { User } from "../../user";
import { Hashtag } from "./hashtag"; import { Hashtag } from "./hashtag";
import { MentionUser } from "./user"; import { MentionUser } from "./user";
@@ -15,7 +14,6 @@ export function MentionNote({
openable = true, openable = true,
}: { eventId: string; openable?: boolean }) { }: { eventId: string; openable?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { addColumn } = useColumnContext();
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => { const richContent = useMemo(() => {
@@ -133,13 +131,6 @@ export function MentionNote({
</Link> </Link>
<button <button
type="button" type="button"
onClick={async () =>
await addColumn({
kind: COL_TYPES.thread,
title: "Thread",
content: data.id,
})
}
className="inline-flex items-center justify-center rounded-md text-neutral-600 dark:text-neutral-400 size-6 bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700" className="inline-flex items-center justify-center rounded-md text-neutral-600 dark:text-neutral-400 size-6 bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
> >
<PinIcon className="size-4" /> <PinIcon className="size-4" />

View File

@@ -1,10 +1,9 @@
import { HorizontalDotsIcon } from "@lume/icons"; import { HorizontalDotsIcon } from "@lume/icons";
import { COL_TYPES } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useArk } from "../../hooks/useArk"; import { useArk } from "../../provider";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteMenu() { export function NoteMenu() {
@@ -19,7 +18,7 @@ export function NoteMenu() {
}; };
const copyRaw = async () => { const copyRaw = async () => {
await writeText(JSON.stringify(await event.toNostrEvent())); await writeText(JSON.stringify(event));
}; };
const copyNpub = async () => { const copyNpub = async () => {

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useArk } from "../../hooks/useArk"; import { useArk } from "../../provider";
import { AppHandler } from "./appHandler"; import { AppHandler } from "./appHandler";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";

View File

@@ -1,6 +1,6 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons"; import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path"; import { downloadDir } from "@tauri-apps/api/path";
import { Window } from "@tauri-apps/api/window"; import { WebviewWindow } from "@tauri-apps/api/webview";
import { download } from "@tauri-apps/plugin-upload"; import { download } from "@tauri-apps/plugin-upload";
import { SyntheticEvent, useState } from "react"; import { SyntheticEvent, useState } from "react";
@@ -23,7 +23,7 @@ export function ImagePreview({ url }: { url: string }) {
const open = async () => { const open = async () => {
const name = new URL(url).pathname.split("/").pop(); const name = new URL(url).pathname.split("/").pop();
return new Window("image-viewer", { return new WebviewWindow("image-viewer", {
url, url,
title: name, title: name,
}); });

View File

@@ -1,5 +1,5 @@
import { NavArrowDownIcon } from "@lume/icons"; import { NavArrowDownIcon } from "@lume/icons";
import { NDKEventWithReplies } from "@lume/types"; import { EventWithReplies } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as Collapsible from "@radix-ui/react-collapsible"; import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react"; import { useState } from "react";
@@ -10,7 +10,7 @@ import { ChildReply } from "./childReply";
export function Reply({ export function Reply({
event, event,
}: { }: {
event: NDKEventWithReplies; event: EventWithReplies;
}) { }) {
const [t] = useTranslation(); const [t] = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);

View File

@@ -4,7 +4,7 @@ import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Note } from ".."; import { Note } from "..";
import { useArk } from "../../../hooks/useArk"; import { useArk } from "../../../provider";
import { User } from "../../user"; import { User } from "../../user";
export function RepostNote({ export function RepostNote({

View File

@@ -1,10 +1,9 @@
import { PinIcon } from "@lume/icons"; import { PinIcon } from "@lume/icons";
import { COL_TYPES, cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Note } from "."; import { Note } from ".";
import { useArk } from "../../hooks/useArk"; import { useArk } from "../../provider";
import { useColumnContext } from "../column/provider";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteThread({ export function NoteThread({

View File

@@ -2,14 +2,11 @@ import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useArk } from "../../hooks/useArk";
export function UserFollowButton({ export function UserFollowButton({
target, target,
className, className,
}: { target: string; className?: string }) { }: { target: string; className?: string }) {
const ark = useArk();
const [t] = useTranslation(); const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);

View File

@@ -8,7 +8,7 @@ export function UserNip05({ className }: { className?: string }) {
const { isLoading, data: verified } = useQuery({ const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.profile.nip05], queryKey: ["nip05", user?.profile.nip05],
queryFn: async ({ signal }: { signal: AbortSignal }) => { queryFn: async () => {
if (!user) return false; if (!user) return false;
if (!user.profile.nip05) return false; if (!user.profile.nip05) return false;
return false; return false;

View File

@@ -1,7 +1,7 @@
import { Metadata } from "@lume/types"; import { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { ReactNode, createContext, useContext } from "react"; import { ReactNode, createContext, useContext } from "react";
import { useArk } from "../../hooks/useArk";
const UserContext = createContext<{ pubkey: string; profile: Metadata }>(null); const UserContext = createContext<{ pubkey: string; profile: Metadata }>(null);
@@ -10,13 +10,12 @@ export function UserProvider({
children, children,
embed, embed,
}: { pubkey: string; children: ReactNode; embed?: string }) { }: { pubkey: string; children: ReactNode; embed?: string }) {
const ark = useArk();
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ["user", pubkey], queryKey: ["user", pubkey],
queryFn: async () => { queryFn: async () => {
if (embed) return JSON.parse(embed) as Metadata; if (embed) return JSON.parse(embed) as Metadata;
const profile = await ark.get_profile(pubkey); const profile: Metadata = await invoke("get_profile", { id: pubkey });
if (!profile) if (!profile)
throw new Error( throw new Error(

View File

@@ -1,10 +0,0 @@
import { useContext } from "react";
import { ArkContext } from "../provider";
export const useArk = () => {
const context = useContext(ArkContext);
if (context === undefined) {
throw new Error("Please import Ark Provider to use useArk() hook");
}
return context;
};

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useArk } from "./useArk"; import { useArk } from "../provider";
export function useEvent(id: string) { export function useEvent(id: string) {
const ark = useArk(); const ark = useArk();

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useArk } from "./useArk"; import { useArk } from "../provider";
export function useProfile(pubkey: string) { export function useProfile(pubkey: string) {
const ark = useArk(); const ark = useArk();

View File

@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useArk } from "./useArk"; import { useArk } from "../provider";
export function useRelaylist() { export function useRelaylist() {
const ark = useArk(); const ark = useArk();

View File

@@ -1,12 +1,9 @@
export * from "./ark";
export * from "./provider"; export * from "./provider";
export * from "./hooks/useEvent"; export * from "./hooks/useEvent";
export * from "./hooks/useArk";
export * from "./hooks/useProfile"; export * from "./hooks/useProfile";
export * from "./hooks/useRelayList"; export * from "./hooks/useRelayList";
export * from "./components/user"; export * from "./components/user";
export * from "./components/column"; export * from "./components/column";
export * from "./components/column/provider";
export * from "./components/note"; export * from "./components/note";
export * from "./components/note/primitives/text"; export * from "./components/note/primitives/text";
export * from "./components/note/primitives/repost"; export * from "./components/note/primitives/repost";

View File

@@ -1,4 +1,4 @@
import { PropsWithChildren, createContext, useEffect, useMemo } from "react"; import { PropsWithChildren, createContext, useContext, useMemo } from "react";
import { Ark } from "./ark"; import { Ark } from "./ark";
export const ArkContext = createContext<Ark>(undefined); export const ArkContext = createContext<Ark>(undefined);
@@ -7,3 +7,11 @@ export const ArkProvider = ({ children }: PropsWithChildren<object>) => {
const ark = useMemo(() => new Ark(), []); const ark = useMemo(() => new Ark(), []);
return <ArkContext.Provider value={ark}>{children}</ArkContext.Provider>; return <ArkContext.Provider value={ark}>{children}</ArkContext.Provider>;
}; };
export const useArk = () => {
const context = useContext(ArkContext);
if (context === undefined) {
throw new Error("Ark Provider is not import");
}
return context;
};

View File

@@ -1,10 +1,10 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { EventRoute, UserRoute } from "@lume/ui"; import { EventRoute, UserRoute } from "@lume/ui";
import { AntenasForm } from "./components/form"; import { AntenasForm } from "./components/form";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function Antenas({ column }: { column: IColumn }) { export function Antenas({ column }: { column: LumeColumn }) {
const colKey = `antenas-${column.id}`; const colKey = `antenas-${column.id}`;
const created = !!column.content?.length; const created = !!column.content?.length;

View File

@@ -1,8 +1,8 @@
import { Column, useColumnContext } from "@lume/ark"; import { Column, useColumnContext } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { COL_TYPES } from "@lume/utils"; import { COL_TYPES } from "@lume/utils";
export function Default({ column }: { column: IColumn }) { export function Default({ column }: { column: LumeColumn }) {
const { addColumn } = useColumnContext(); const { addColumn } = useColumnContext();
return ( return (

View File

@@ -1,13 +1,13 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { useStorage } from "@lume/storage"; import { useStorage } from "@lume/storage";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { EventRoute, UserRoute } from "@lume/ui"; import { EventRoute, UserRoute } from "@lume/ui";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useRef } from "react"; import { useRef } from "react";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function ForYou({ column }: { column: IColumn }) { export function ForYou({ column }: { column: LumeColumn }) {
const colKey = `foryou-${column.id}`; const colKey = `foryou-${column.id}`;
const storage = useStorage(); const storage = useStorage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -1,9 +1,9 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { EventRoute, UserRoute } from "@lume/ui"; import { EventRoute, UserRoute } from "@lume/ui";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function Global({ column }: { column: IColumn }) { export function Global({ column }: { column: LumeColumn }) {
const colKey = `global-${column.id}`; const colKey = `global-${column.id}`;
return ( return (

View File

@@ -1,10 +1,10 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { EventRoute, UserRoute } from "@lume/ui"; import { EventRoute, UserRoute } from "@lume/ui";
import { GroupForm } from "./components/form"; import { GroupForm } from "./components/form";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function Group({ column }: { column: IColumn }) { export function Group({ column }: { column: LumeColumn }) {
const colKey = `group-${column.id}`; const colKey = `group-${column.id}`;
const created = !!column.content?.length; const created = !!column.content?.length;

View File

@@ -1,9 +1,9 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { EventRoute, UserRoute } from "@lume/ui"; import { EventRoute, UserRoute } from "@lume/ui";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function Hashtag({ column }: { column: IColumn }) { export function Hashtag({ column }: { column: LumeColumn }) {
const colKey = `hashtag-${column.id}`; const colKey = `hashtag-${column.id}`;
const hashtag = column.content.replace("#", ""); const hashtag = column.content.replace("#", "");

View File

@@ -1,9 +1,9 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
import { EventRoute, UserRoute } from "@lume/ui"; import { EventRoute, UserRoute } from "@lume/ui";
export function Thread({ column }: { column: IColumn }) { export function Thread({ column }: { column: LumeColumn }) {
return ( return (
<Column.Root> <Column.Root>
<Column.Header id={column.id} title={column.title} /> <Column.Header id={column.id} title={column.title} />

View File

@@ -3,16 +3,15 @@ import { ArrowRightCircleIcon, LoaderIcon, SearchIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { Event, Kind } from "@lume/types";
import { EmptyFeed } from "@lume/ui"; import { EmptyFeed } from "@lume/ui";
import { FETCH_LIMIT } from "@lume/utils"; import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CacheSnapshot, VList, VListHandle } from "virtua"; import { CacheSnapshot, VList, VListHandle } from "virtua";
export function HomeRoute({ colKey }: { colKey: string }) { export function HomeRoute({ queryKey }: { queryKey: string }) {
const ark = useArk(); const ark = useArk();
const ref = useRef<VListHandle>(); const ref = useRef<VListHandle>();
const cacheKey = `${colKey}-vlist`; const cacheKey = `${queryKey}-vlist`;
const [offset, cache] = useMemo(() => { const [offset, cache] = useMemo(() => {
const serialized = sessionStorage.getItem(cacheKey); const serialized = sessionStorage.getItem(cacheKey);
@@ -22,16 +21,14 @@ export function HomeRoute({ colKey }: { colKey: string }) {
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } = const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: [colKey], queryKey: [queryKey],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ queryFn: async ({
signal,
pageParam, pageParam,
}: { }: {
signal: AbortSignal;
pageParam: number; pageParam: number;
}) => { }) => {
const events = await ark.get_text_events(FETCH_LIMIT); const events = await ark.get_text_events(FETCH_LIMIT, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
@@ -71,23 +68,6 @@ export function HomeRoute({ colKey }: { colKey: string }) {
}; };
}, []); }, []);
/*
if (!ark.account.contacts.length) {
return (
<div className="px-3 mt-3">
<EmptyFeed />
<Link
to="/suggest"
className="mt-3 w-full gap-2 inline-flex items-center justify-center text-sm font-medium rounded-lg h-9 bg-blue-500 hover:bg-blue-600 text-white"
>
<SearchIcon className="size-5" />
Find accounts to follow
</Link>
</div>
);
}
*/
return ( return (
<div className="w-full h-full"> <div className="w-full h-full">
<VList ref={ref} cache={cache} overscan={2} className="flex-1 px-3"> <VList ref={ref} cache={cache} overscan={2} className="flex-1 px-3">

View File

@@ -1,51 +1,25 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { EventRoute, SuggestRoute, UserRoute } from "@lume/ui"; import { EventRoute, SuggestRoute, UserRoute } from "@lume/ui";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function Timeline({ column }: { column: IColumn }) { export function Timeline({ column }: { column: LumeColumn }) {
const colKey = `timeline-${column.id}`; const colKey = `timeline-${column.id}`;
// const ark = useArk();
// const queryClient = useQueryClient();
// const since = useRef(Math.floor(Date.now() / 1000));
/*
const refresh = async (events: NDKEvent[]) => {
const uniqEvents = new Set(events);
await queryClient.setQueryData(
[colKey],
(prev: { pageParams: number; pages: Array<NDKEvent[]> }) => ({
...prev,
pages: [[...uniqEvents], ...prev.pages],
}),
);
};
*/
return ( return (
<Column.Root> <Column.Provider column={column}>
{/*<Column.Header id={column.id} queryKey={[colKey]} title="Timeline" />*/} <Column.Root>
{/*ark.account.contacts.length ? ( <Column.Header queryKey={[colKey]} />
<Column.Live <Column.Content>
filter={{ <Column.Route path="/" element={<HomeRoute queryKey={colKey} />} />
kinds: [NDKKind.Text, NDKKind.Repost],
authors: ark.account.contacts,
since: since.current,
}}
onClick={refresh}
/>
) : null*/}
<Column.Content>
<Column.Route path="/" element={<HomeRoute colKey={colKey} />} />
{/*
<Column.Route path="/events/:id" element={<EventRoute />} /> <Column.Route path="/events/:id" element={<EventRoute />} />
<Column.Route path="/users/:id" element={<UserRoute />} /> <Column.Route path="/users/:id" element={<UserRoute />} />
<Column.Route <Column.Route
path="/suggest" path="/suggest"
element={<SuggestRoute queryKey={[colKey]} />} element={<SuggestRoute queryKey={colKey} />}
/> />
*/} </Column.Content>
</Column.Content> </Column.Root>
</Column.Root> </Column.Provider>
); );
} }

View File

@@ -1,9 +1,9 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { EventRoute, UserRoute } from "@lume/ui"; import { EventRoute, UserRoute } from "@lume/ui";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function TrendingNotes({ column }: { column: IColumn }) { export function TrendingNotes({ column }: { column: LumeColumn }) {
const colKey = `trending-notes-${column.id}`; const colKey = `trending-notes-${column.id}`;
return ( return (

View File

@@ -1,9 +1,9 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { EventRoute, UserRoute } from "@lume/ui"; import { EventRoute, UserRoute } from "@lume/ui";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function User({ column }: { column: IColumn }) { export function User({ column }: { column: LumeColumn }) {
return ( return (
<Column.Root> <Column.Root>
<Column.Header id={column.id} title={column.title} /> <Column.Header id={column.id} title={column.title} />

View File

@@ -1,8 +1,8 @@
import { Column } from "@lume/ark"; import { Column } from "@lume/ark";
import { IColumn } from "@lume/types"; import { LumeColumn } from "@lume/types";
import { HomeRoute } from "./home"; import { HomeRoute } from "./home";
export function Waifu({ column }: { column: IColumn }) { export function Waifu({ column }: { column: LumeColumn }) {
const colKey = `waifu-${column.id}`; const colKey = `waifu-${column.id}`;
return ( return (

View File

@@ -1,442 +0,0 @@
// inspired by NDK Cache Dexie
// source: https://github.com/nostr-dev-kit/ndk/tree/master/ndk-cache-dexie
import { LumeStorage } from "@lume/storage";
import {
Hexpubkey,
NDKCacheAdapter,
NDKEvent,
NDKFilter,
NDKRelay,
NDKSubscription,
NDKUserProfile,
profileFromEvent,
} from "@nostr-dev-kit/ndk";
import { LRUCache } from "lru-cache";
import { NostrEvent } from "nostr-fetch";
import { matchFilter } from "nostr-tools";
export class NDKCacheAdapterTauri implements NDKCacheAdapter {
#storage: LumeStorage;
private dirtyProfiles: Set<Hexpubkey> = new Set();
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
readonly locking: boolean;
constructor(storage: LumeStorage) {
this.#storage = storage;
this.locking = true;
this.profiles = new LRUCache({
max: 100000,
});
setInterval(() => {
this.dumpProfiles();
}, 1000 * 10);
}
public async query(subscription: NDKSubscription): Promise<void> {
Promise.allSettled(
subscription.filters.map((filter) =>
this.processFilter(filter, subscription),
),
);
}
public async fetchProfile(pubkey: Hexpubkey) {
if (!this.profiles) return null;
let profile = this.profiles.get(pubkey);
if (!profile) {
const user = await this.#storage.getCacheUser(pubkey);
if (user) {
profile = user.profile as NDKUserProfile;
this.profiles.set(pubkey, profile);
}
}
return profile;
}
public saveProfile(pubkey: Hexpubkey, profile: NDKUserProfile) {
if (!this.profiles) return;
this.profiles.set(pubkey, profile);
this.dirtyProfiles.add(pubkey);
}
private async processFilter(
filter: NDKFilter,
subscription: NDKSubscription,
): Promise<void> {
const _filter = { ...filter };
_filter.limit = undefined;
const filterKeys = Object.keys(_filter || {})
.sort()
.filter((e) => e !== "limit");
try {
await Promise.allSettled([
this.byKindAndAuthor(filterKeys, filter, subscription),
this.byAuthors(filterKeys, filter, subscription),
this.byKinds(filterKeys, filter, subscription),
this.byIdsQuery(filterKeys, filter, subscription),
this.byNip33Query(filterKeys, filter, subscription),
this.byTagsAndOptionallyKinds(filterKeys, filter, subscription),
]);
} catch (error) {
console.error(error);
}
}
public async setEvent(
event: NDKEvent,
filters: NDKFilter[],
relay?: NDKRelay,
): Promise<void> {
if (event.kind === 0) {
if (!this.profiles) return;
const profile: NDKUserProfile = profileFromEvent(event);
this.profiles.set(event.pubkey, profile);
} else {
let addEvent = true;
if (event.isParamReplaceable()) {
const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
const existingEvent = await this.#storage.getCacheEvent(replaceableId);
if (
existingEvent &&
event.created_at &&
existingEvent.createdAt > event.created_at
) {
addEvent = false;
}
}
if (addEvent) {
this.#storage.setCacheEvent({
id: event.tagId(),
pubkey: event.pubkey,
content: event.content,
// biome-ignore lint/style/noNonNullAssertion: <explanation>
kind: event.kind!,
// biome-ignore lint/style/noNonNullAssertion: <explanation>
createdAt: event.created_at!,
relay: relay?.url,
event: JSON.stringify(event.rawEvent()),
});
// Don't cache contact lists as tags since it's expensive
// and there is no use case for it
if (event.kind !== 3) {
for (const tag of event.tags) {
if (tag[0].length !== 1) return;
this.#storage.setCacheEventTag({
id: `${event.id}:${tag[0]}:${tag[1]}`,
eventId: event.id,
tag: tag[0],
value: tag[1],
tagValue: tag[0] + tag[1],
});
}
}
}
}
}
/**
* Searches by authors
*/
private async byAuthors(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription,
): Promise<boolean> {
const f = ["authors"];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (hasAllKeys && filter.authors) {
for (const pubkey of filter.authors) {
const events = await this.#storage.getCacheEventsByPubkey(pubkey);
for (const event of events) {
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log("failed to parse event", e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
return foundEvents;
}
/**
* Searches by kinds
*/
private async byKinds(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription,
): Promise<boolean> {
const f = ["kinds"];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (hasAllKeys && filter.kinds) {
for (const kind of filter.kinds) {
const events = await this.#storage.getCacheEventsByKind(kind);
for (const event of events) {
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log("failed to parse event", e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
return foundEvents;
}
/**
* Searches by ids
*/
private async byIdsQuery(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription,
): Promise<boolean> {
const f = ["ids"];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
if (hasAllKeys && filter.ids) {
for (const id of filter.ids) {
const event = await this.#storage.getCacheEvent(id);
if (!event) continue;
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log("failed to parse event", e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
}
return true;
}
return false;
}
/**
* Searches by NIP-33
*/
private async byNip33Query(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription,
): Promise<boolean> {
const f = ["#d", "authors", "kinds"];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
if (hasAllKeys && filter.kinds && filter.authors) {
for (const kind of filter.kinds) {
const replaceableKind = kind >= 30000 && kind < 40000;
if (!replaceableKind) continue;
for (const author of filter.authors) {
for (const dTag of filter["#d"]) {
const replaceableId = `${kind}:${author}:${dTag}`;
const event = await this.#storage.getCacheEvent(replaceableId);
if (!event) continue;
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log("failed to parse event", e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
}
}
}
return true;
}
return false;
}
/**
* Searches by kind & author
*/
private async byKindAndAuthor(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription,
): Promise<boolean> {
const f = ["authors", "kinds"];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (!hasAllKeys) return false;
if (filter.kinds && filter.authors) {
for (const kind of filter.kinds) {
for (const author of filter.authors) {
const events = await this.#storage.getCacheEventsByKindAndAuthor(
kind,
author,
);
for (const event of events) {
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log("failed to parse event", e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
}
return foundEvents;
}
/**
* Searches by tags and optionally filters by tags
*/
private async byTagsAndOptionallyKinds(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription,
): Promise<boolean> {
for (const filterKey of filterKeys) {
const isKind = filterKey === "kinds";
const isTag = filterKey.startsWith("#") && filterKey.length === 2;
if (!isKind && !isTag) return false;
}
const events = await this.filterByTag(filterKeys, filter);
const kinds = filter.kinds as number[];
for (const event of events) {
// biome-ignore lint/style/noNonNullAssertion: <explanation>
if (!kinds?.includes(event.kind!)) continue;
subscription.eventReceived(event, undefined, true);
}
return false;
}
private async filterByTag(
filterKeys: string[],
filter: NDKFilter,
): Promise<NDKEvent[]> {
const retEvents: NDKEvent[] = [];
for (const filterKey of filterKeys) {
if (filterKey.length !== 2) continue;
const tag = filterKey.slice(1);
// const values = filter[filterKey] as string[];
const values: string[] = [];
for (const [key, value] of Object.entries(filter)) {
if (key === filterKey) values.push(value as string);
}
for (const value of values) {
const eventTags = await this.#storage.getCacheEventTagsByTagValue(
tag + value,
);
if (!eventTags.length) continue;
const eventIds = eventTags.map((t) => t.eventId);
const events = await this.#storage.getCacheEvents(eventIds);
for (const event of events) {
let rawEvent: NostrEvent;
try {
rawEvent = JSON.parse(event.event);
// Make sure all passed filters match the event
if (!matchFilter(filter, rawEvent)) continue;
} catch (e) {
console.log("failed to parse event", e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
ndkEvent.relay = relay;
retEvents.push(ndkEvent);
}
}
}
return retEvents;
}
private async dumpProfiles(): Promise<void> {
const profiles = [];
if (!this.profiles) return;
for (const pubkey of this.dirtyProfiles) {
const profile = this.profiles.get(pubkey);
if (!profile) continue;
profiles.push({
pubkey,
profile: JSON.stringify(profile),
createdAt: Date.now(),
});
}
if (profiles.length) {
await this.#storage.setCacheProfiles(profiles);
}
this.dirtyProfiles.clear();
}
}

View File

@@ -1,23 +0,0 @@
{
"name": "@lume/ndk-cache-tauri",
"version": "0.0.0",
"main": "./index.ts",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@lume/storage": "workspace:*",
"@nostr-dev-kit/ndk": "^2.4.0",
"lru-cache": "^10.2.0",
"nostr-fetch": "^0.15.0",
"nostr-tools": "1.17.0",
"react": "^18.2.0"
},
"devDependencies": {
"@lume/tsconfig": "workspace:*",
"@types/react": "^18.2.52",
"typescript": "^5.3.3"
}
}

View File

@@ -1,7 +0,0 @@
{
"extends": "@lume/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -9,7 +9,11 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/plugin-store": "2.0.0-beta.0", "@tauri-apps/plugin-store": "2.0.0-beta.0",
"react": "^18.2.0" "react": "^18.2.0",
"scheduler": "^0.23.0",
"use-context-selector": "^1.4.1",
"virtua": "^0.23.3",
"zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@lume/tsconfig": "workspace:*", "@lume/tsconfig": "workspace:*",

View File

@@ -1,2 +1 @@
export * from "./storage";
export * from "./provider"; export * from "./provider";

View File

@@ -1,26 +1,118 @@
import { LumeColumn } from "@lume/types";
import { locale, platform } from "@tauri-apps/plugin-os"; import { locale, platform } from "@tauri-apps/plugin-os";
import { Store } from "@tauri-apps/plugin-store"; import { Store } from "@tauri-apps/plugin-store";
import { PropsWithChildren, createContext, useContext } from "react"; import {
MutableRefObject,
PropsWithChildren,
useCallback,
useRef,
useState,
} from "react";
import { createContext, useContextSelector } from "use-context-selector";
import { type VListHandle } from "virtua";
import { LumeStorage } from "./storage"; import { LumeStorage } from "./storage";
const StorageContext = createContext<LumeStorage>(null);
const store = new Store("lume.data");
const platformName = await platform(); const platformName = await platform();
const osLocale = await locale(); const osLocale = await locale();
const db = new LumeStorage(store, platformName, osLocale); const store = new Store("lume.dat");
const storage = new LumeStorage(store, platformName, osLocale);
await storage.init();
type StorageContext = {
storage: LumeStorage;
column: {
columns: LumeColumn[];
vlistRef: MutableRefObject<VListHandle>;
create: (column: LumeColumn) => void;
remove: (id: number) => void;
move: (id: number, position: "left" | "right") => void;
update: (id: number, title: string, content: string) => void;
};
};
const StorageContext = createContext<StorageContext>(null);
export const StorageProvider = ({ children }: PropsWithChildren<object>) => { export const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const vlistRef = useRef<VListHandle>(null);
const [columns, setColumns] = useState<LumeColumn[]>([
{
id: 1,
title: "Newsfeed",
content: "",
},
{
id: 2,
title: "For You",
content: "",
},
]);
const create = useCallback((column: LumeColumn) => {
setColumns((prev) => [...prev, column]);
vlistRef?.current.scrollToIndex(columns.length);
}, []);
const remove = useCallback((id: number) => {
setColumns((prev) => prev.filter((t) => t.id !== id));
}, []);
const update = useCallback(
(id: number, title: string, content: string) => {
const newCols = columns.map((col) => {
if (col.id === id) {
return { ...col, title, content };
}
return col;
});
setColumns(newCols);
},
[columns],
);
const move = useCallback(
(id: number, position: "left" | "right") => {
const newCols = [...columns];
const col = newCols.find((el) => el.id === id);
const colIndex = newCols.findIndex((el) => el.id === id);
newCols.splice(colIndex, 1);
if (position === "left") newCols.splice(colIndex - 1, 0, col);
if (position === "right") newCols.splice(colIndex + 1, 0, col);
setColumns(newCols);
},
[columns],
);
return ( return (
<StorageContext.Provider value={db}>{children}</StorageContext.Provider> <StorageContext.Provider
value={{
storage,
column: { columns, vlistRef, create, remove, move, update },
}}
>
{children}
</StorageContext.Provider>
); );
}; };
export const useStorage = () => { export const useStorage = () => {
const context = useContext(StorageContext); const context = useContextSelector(StorageContext, (state) => state.storage);
if (context === undefined) { if (context === undefined) {
throw new Error("Please import Storage Provider to use useStorage() hook"); throw new Error("Storage Provider is required");
}
return context;
};
export const useColumn = () => {
const context = useContextSelector(StorageContext, (state) => state.column);
if (context === undefined) {
throw new Error("Storage Provider is required");
} }
return context; return context;
}; };

View File

@@ -1,3 +1,4 @@
import { Settings } from "@lume/types";
import { Platform } from "@tauri-apps/plugin-os"; import { Platform } from "@tauri-apps/plugin-os";
import { Store } from "@tauri-apps/plugin-store"; import { Store } from "@tauri-apps/plugin-store";
@@ -5,17 +6,7 @@ export class LumeStorage {
#store: Store; #store: Store;
readonly platform: Platform; readonly platform: Platform;
readonly locale: string; readonly locale: string;
public settings: { public settings: Settings;
autoupdate: boolean;
nsecbunker: boolean;
media: boolean;
hashtag: boolean;
lowPower: boolean;
translation: boolean;
translateApiKey: string;
instantZap: boolean;
defaultZapAmount: number;
};
constructor(store: Store, platform: Platform, locale: string) { constructor(store: Store, platform: Platform, locale: string) {
this.#store = store; this.#store = store;
@@ -34,8 +25,24 @@ export class LumeStorage {
}; };
} }
public async createSetting(key: string, value: string | boolean) { public async init() {
this.loadSettings();
}
public async loadSettings() {
const settings: Settings = JSON.parse(await this.#store.get("settings"));
for (const [key, value] of Object.entries(settings)) {
this.settings[key] = value;
}
}
public async createSetting(key: string, value: string | number | boolean) {
this.settings[key] = value; this.settings[key] = value;
await this.#store.set(this.settings[key], { value });
const settings: Settings = JSON.parse(await this.#store.get("settings"));
const newSettings = { ...settings, key: value };
await this.#store.set("settings", newSettings);
await this.#store.save();
} }
} }

View File

@@ -1,4 +1,14 @@
import { type NDKEvent, type NDKUserProfile } from "@nostr-dev-kit/ndk"; export interface Settings {
autoupdate: boolean;
nsecbunker: boolean;
media: boolean;
hashtag: boolean;
lowPower: boolean;
translation: boolean;
translateApiKey: string;
instantZap: boolean;
defaultZapAmount: number;
}
export interface Keys { export interface Keys {
npub: string; npub: string;
@@ -28,16 +38,20 @@ export interface Event {
sig: string; sig: string;
} }
export interface EventWithReplies extends Event {
replies: Array<Event>;
}
export interface Metadata { export interface Metadata {
name: Option<string>; name?: string;
display_name: Option<string>; display_name?: string;
about: Option<string>; about?: string;
website: Option<string>; website?: string;
picture: Option<string>; picture?: string;
banner: Option<string>; banner?: string;
nip05: Option<string>; nip05?: string;
lud06: Option<string>; lud06?: string;
lud16: Option<string>; lud16?: string;
} }
export interface CurrentAccount { export interface CurrentAccount {
@@ -61,9 +75,8 @@ export interface RichContent {
notes: string[]; notes: string[];
} }
export interface IColumn { export interface LumeColumn {
id?: number; id: number;
kind: number;
title: string; title: string;
content: string; content: string;
} }
@@ -75,10 +88,6 @@ export interface Opengraph {
image?: string; image?: string;
} }
export interface NDKEventWithReplies extends NDKEvent {
replies: Array<NDKEvent>;
}
export interface NostrBuildResponse { export interface NostrBuildResponse {
ok: boolean; ok: boolean;
data?: { data?: {
@@ -99,34 +108,6 @@ export interface NostrBuildResponse {
}; };
} }
export interface NDKCacheUser {
pubkey: string;
profile: string | NDKUserProfile;
createdAt: number;
}
export interface NDKCacheUserProfile extends NDKUserProfile {
npub: string;
}
export interface NDKCacheEvent {
id: string;
pubkey: string;
content: string;
kind: number;
createdAt: number;
relay: string;
event: string;
}
export interface NDKCacheEventTag {
id: string;
eventId: string;
tag: string;
value: string;
tagValue: string;
}
export interface NIP11 { export interface NIP11 {
name: string; name: string;
description: string; description: string;

View File

@@ -10,8 +10,5 @@
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.3.3" "typescript": "^5.3.3"
},
"dependencies": {
"@nostr-dev-kit/ndk": "^2.4.0"
} }
} }

View File

@@ -1,20 +1,11 @@
import { useStorage } from "@lume/storage";
import { cn } from "@lume/utils";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Editor } from "../editor/column"; import { Editor } from "../editor/column";
import { Navigation } from "../navigation"; import { Navigation } from "../navigation";
import { SearchDialog } from "../search/dialog"; import { SearchDialog } from "../search/dialog";
export function AppLayout() { export function AppLayout() {
const storage = useStorage();
return ( return (
<div <div className="flex h-screen w-screen flex-col bg-gradient-to-tl from-neutral-50 to-neutral-200 dark:from-neutral-950 dark:to-neutral-800">
className={cn(
"flex h-screen w-screen flex-col",
storage.platform !== "macos" ? "bg-neutral-50 dark:bg-neutral-950" : "",
)}
>
<div data-tauri-drag-region className="h-9 shrink-0" /> <div data-tauri-drag-region className="h-9 shrink-0" />
<div className="flex w-full h-full min-h-0"> <div className="flex w-full h-full min-h-0">
<Navigation /> <Navigation />

View File

@@ -25,7 +25,7 @@ const LUME_USERS = [
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445", "npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
]; ];
export function SuggestRoute({ queryKey }: { queryKey: string[] }) { export function SuggestRoute({ queryKey }: { queryKey: string }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -45,7 +45,7 @@ export function SuggestRoute({ queryKey }: { queryKey: string[] }) {
const submit = async () => { const submit = async () => {
try { try {
await queryClient.refetchQueries({ queryKey }); await queryClient.refetchQueries({ queryKey: [queryKey] });
return navigate("/", { replace: true }); return navigate("/", { replace: true });
} catch (e) { } catch (e) {
toast.error(String(e)); toast.error(String(e));

61
pnpm-lock.yaml generated
View File

@@ -1061,6 +1061,18 @@ importers:
react: react:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.2.0 version: 18.2.0
scheduler:
specifier: ^0.23.0
version: 0.23.0
use-context-selector:
specifier: ^1.4.1
version: 1.4.1(react@18.2.0)(scheduler@0.23.0)
virtua:
specifier: ^0.23.3
version: 0.23.3(react-dom@18.2.0)(react@18.2.0)
zustand:
specifier: ^4.5.0
version: 4.5.0(@types/react@18.2.52)(react@18.2.0)
devDependencies: devDependencies:
'@lume/tsconfig': '@lume/tsconfig':
specifier: workspace:* specifier: workspace:*
@@ -1100,10 +1112,6 @@ importers:
packages/tsconfig: {} packages/tsconfig: {}
packages/types: packages/types:
dependencies:
'@nostr-dev-kit/ndk':
specifier: ^2.4.0
version: 2.4.0(typescript@5.3.3)
devDependencies: devDependencies:
typescript: typescript:
specifier: ^5.3.3 specifier: ^5.3.3
@@ -9812,6 +9820,23 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
/use-context-selector@1.4.1(react@18.2.0)(scheduler@0.23.0):
resolution: {integrity: sha512-Io2ArvcRO+6MWIhkdfMFt+WKQX+Vb++W8DS2l03z/Vw/rz3BclKpM0ynr4LYGyU85Eke+Yx5oIhTY++QR0ZDoA==}
peerDependencies:
react: '>=16.8.0'
react-dom: '*'
react-native: '*'
scheduler: '>=0.19.0'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
dependencies:
react: 18.2.0
scheduler: 0.23.0
dev: false
/use-debounce@10.0.0(react@18.2.0): /use-debounce@10.0.0(react@18.2.0):
resolution: {integrity: sha512-XRjvlvCB46bah9IBXVnq/ACP2lxqXyZj0D9hj4K5OzNroMDpTEBg8Anuh1/UfRTRs7pLhQ+RiNxxwZu9+MVl1A==} resolution: {integrity: sha512-XRjvlvCB46bah9IBXVnq/ACP2lxqXyZj0D9hj4K5OzNroMDpTEBg8Anuh1/UfRTRs7pLhQ+RiNxxwZu9+MVl1A==}
engines: {node: '>= 16.0.0'} engines: {node: '>= 16.0.0'}
@@ -9837,6 +9862,14 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/utf-8-validate@5.0.10: /utf-8-validate@5.0.10:
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
engines: {node: '>=6.14.2'} engines: {node: '>=6.14.2'}
@@ -10395,5 +10428,25 @@ packages:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
dev: false dev: false
/zustand@4.5.0(@types/react@18.2.52)(react@18.2.0):
resolution: {integrity: sha512-zlVFqS5TQ21nwijjhJlx4f9iGrXSL0o/+Dpy4txAP22miJ8Ti6c1Ol1RLNN98BMib83lmDH/2KmLwaNXpjrO1A==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
dependencies:
'@types/react': 18.2.52
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/zwitch@2.0.4: /zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}

View File

@@ -19,6 +19,9 @@
"notification:default", "notification:default",
"os:allow-locale", "os:allow-locale",
"os:allow-platform", "os:allow-platform",
"updater:allow-check",
"updater:default",
"window:allow-start-dragging",
{ {
"identifier": "http:default", "identifier": "http:default",
"allow": [ "allow": [

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",{"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",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}}

View File

@@ -1,19 +1,17 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json", "$schema": "../node_modules/@tauri-apps/cli/schema.json",
"app": { "app": {
"windows": [ "windows": [
{ {
"width": 1080, "title": "Lume",
"height": 800, "label": "main",
"minWidth": 1080, "titleBarStyle": "Overlay",
"minHeight": 800, "width": 1080,
"resizable": true, "height": 800,
"title": "Lume", "minWidth": 1080,
"center": true, "minHeight": 800,
"fullscreen": false, "center": true
"fileDropEnabled": true, }
"decorations": true ]
} }
]
}
} }

View File

@@ -1,27 +1,19 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json", "$schema": "../node_modules/@tauri-apps/cli/schema.json",
"app": { "app": {
"windows": [ "windows": [
{ {
"width": 1080, "title": "Lume",
"height": 800, "label": "main",
"minWidth": 1080, "titleBarStyle": "Overlay",
"minHeight": 800, "width": 1080,
"resizable": true, "height": 800,
"title": "Lume", "minWidth": 1080,
"titleBarStyle": "Overlay", "minHeight": 800,
"center": true, "center": true,
"fullscreen": false, "hiddenTitle": true,
"hiddenTitle": true, "decorations": true
"fileDropEnabled": true, }
"decorations": true, ]
"transparent": true, }
"windowEffects": {
"effects": [
"sidebar"
]
}
}
]
}
} }

View File

@@ -1,20 +1,16 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/schema.json", "$schema": "../node_modules/@tauri-apps/cli/schema.json",
"app": { "app": {
"windows": [ "windows": [
{ {
"width": 1080, "title": "Lume",
"height": 800, "label": "main",
"minWidth": 1080, "width": 1080,
"minHeight": 800, "height": 800,
"resizable": true, "minWidth": 1080,
"title": "Lume", "minHeight": 800,
"center": true, "center": true
"fullscreen": false, }
"hiddenTitle": true, ]
"fileDropEnabled": true, }
"decorations": false
}
]
}
} }