Compare commits

...

11 Commits

Author SHA1 Message Date
reya
6d42360549 chore: bump version 2024-05-29 14:47:22 +07:00
reya
70c5143445 feat: redirect to index after add account 2024-05-29 14:41:33 +07:00
reya
41b66b18f5 feat: lazy load onboarding videos 2024-05-29 14:11:30 +07:00
雨宮蓮
dda0720ed4 chore: update deps (#195) 2024-05-29 12:54:58 +07:00
reya
4b60b39119 chore: bump version 2024-05-29 10:34:32 +07:00
reya
d2e5122d5a feat: improve splash screen and notification service 2024-05-29 10:33:38 +07:00
reya
32f3315344 fix: editor crash on open 2024-05-28 14:43:34 +07:00
reya
5ca9444358 feat: add theme switcher 2024-05-26 16:11:27 +07:00
reya
4dc13385a5 feat: add option for relay hint 2024-05-26 08:23:29 +07:00
XIAO YU
b90ad1421f chore: update keys.rs and relay.rs (#193) 2024-05-26 07:28:16 +07:00
雨宮蓮
bba324ea53 refactor: use specta for commands (#192)
* feat: add tauri-specta

* refactor: system library

* chore: format
2024-05-25 15:21:40 +07:00
120 changed files with 3270 additions and 3254 deletions

View File

@@ -9,8 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/system": "workspace:^",
"@lume/ui": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4",
@@ -22,42 +22,42 @@
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-sync-storage-persister": "^5.36.0",
"@tanstack/react-query": "^5.36.0",
"@tanstack/react-query-persist-client": "^5.36.0",
"@tanstack/react-router": "1.32.5",
"i18next": "^23.11.4",
"@tanstack/query-sync-storage-persister": "^5.40.0",
"@tanstack/react-query": "^5.40.0",
"@tanstack/react-query-persist-client": "^5.40.0",
"@tanstack/react-router": "^1.34.5",
"i18next": "^23.11.5",
"i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.5.2",
"nostr-tools": "^2.6.0",
"react": "^18.3.1",
"react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.4",
"react-hook-form": "^7.51.5",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.1",
"react-i18next": "^14.1.2",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.102.0",
"slate-react": "^0.104.0",
"sonner": "^1.4.41",
"use-debounce": "^10.0.0",
"use-debounce": "^10.0.1",
"virtua": "^0.31.0"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.32.5",
"@tanstack/router-vite-plugin": "^1.32.2",
"@types/react": "^18.3.2",
"@tanstack/router-devtools": "^1.34.5",
"@tanstack/router-vite-plugin": "^1.34.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite": "^5.2.12",
"vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -1,20 +1,17 @@
import { Ark } from "@lume/ark";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { platform } from "@tauri-apps/plugin-os";
import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import "./app.css";
import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file
import { type } from "@tauri-apps/plugin-os";
const ark = new Ark();
const os = await type();
const queryClient = new QueryClient();
const platformName = await platform();
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
@@ -23,9 +20,8 @@ const persister = createSyncStoragePersister({
const router = createRouter({
routeTree,
context: {
ark,
queryClient,
platform: platformName,
platform: os,
},
Wrap: ({ children }) => {
return (

View File

@@ -1,6 +1,6 @@
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import {
type Dispatch,
type ReactNode,
@@ -18,21 +18,17 @@ export function AvatarUploader({
children: ReactNode;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
// start loading
setLoading(true);
try {
const image = await ark.upload();
setLoading(true);
const image = await NostrQuery.upload();
setPicture(image);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
// stop loading
setLoading(false);
};
return (

View File

@@ -1,16 +1,15 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) {
const { ark } = useRouteContext({ strict: false });
const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => {
async function getBalance() {
const val = await ark.get_balance();
const val = await NostrAccount.getBalance();
setBalance(val);
}

View File

@@ -1,18 +1,23 @@
import { ThreadIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { LumeEvent } from "@lume/system";
import { useMemo } from "react";
export function Conversation({
event,
gossip,
className,
}: {
event: Event;
event: NostrEvent;
gossip?: boolean;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const thread = ark.get_thread(event.tags);
const thread = useMemo(
() => LumeEvent.getEventThread(event.tags, gossip),
[event],
);
return (
<Note.Provider event={event}>

View File

@@ -4,9 +4,7 @@ import { User } from "../user";
export function NoteActivity({ className }: { className?: string }) {
const event = useNoteContext();
const mentions = event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1]);
const mentions = event.mentions;
return (
<div className={cn("-mt-3 mb-2", className)}>

View File

@@ -1,11 +1,10 @@
import { VisitIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
import { LumeWindow } from "@lume/system";
export function NoteOpenThread() {
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return (
<Tooltip.Provider>
@@ -13,7 +12,7 @@ export function NoteOpenThread() {
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => ark.open_event(event)}
onClick={() => LumeWindow.openEvent(event)}
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
>
<VisitIcon className="shrink-0 size-4" />

View File

@@ -1,12 +1,11 @@
import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
import { cn } from "@lume/utils";
import { LumeWindow } from "@lume/system";
export function NoteReply({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return (
<Tooltip.Provider>
@@ -14,7 +13,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => ark.open_editor(event.id)}
onClick={() => LumeWindow.openEditor(event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large

View File

@@ -2,15 +2,14 @@ import { QuoteIcon, RepostIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Spinner } from "@lume/ui";
import { useNoteContext } from "../provider";
import { LumeWindow } from "@lume/system";
export function NoteRepost({ large = false }: { large?: boolean }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
const [t] = useTranslation();
@@ -23,7 +22,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
setLoading(true);
// repost
await ark.repost(event.id, event.pubkey);
await event.repost();
// update state
setLoading(false);
@@ -86,7 +85,7 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_editor(event.id, true)}
onClick={() => LumeWindow.openEditor(event.id, true)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<QuoteIcon className="size-4" />

View File

@@ -1,33 +1,19 @@
import { ZapIcon } from "@lume/icons";
import { useRouteContext, useSearch } from "@tanstack/react-router";
import { toast } from "sonner";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
import { cn } from "@lume/utils";
import { LumeWindow } from "@lume/system";
export function NoteZap({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { ark, settings } = useRouteContext({ strict: false });
const { account } = useSearch({ strict: false });
const zap = async () => {
try {
const nwc = await ark.load_nwc();
if (!nwc) {
ark.open_nwc();
} else {
ark.open_zap(event.id, event.pubkey, account);
}
} catch (e) {
toast.error(String(e));
}
};
const { settings } = useRouteContext({ strict: false });
if (!settings.zap) return null;
return (
<button
type="button"
onClick={() => zap()}
onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large

View File

@@ -1,4 +1,4 @@
import { useEvent } from "@lume/ark";
import { useEvent } from "@lume/system";
import { cn } from "@lume/utils";
import { Note } from ".";
import { InfoIcon } from "@lume/icons";

View File

@@ -1,6 +1,5 @@
import { useEvent } from "@lume/ark";
import { LumeWindow, useEvent } from "@lume/system";
import { LinkIcon } from "@lume/icons";
import { useRouteContext } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils";
import { User } from "@/components/user";
@@ -13,7 +12,6 @@ export function MentionNote({
eventId: string;
openable?: boolean;
}) {
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
@@ -62,9 +60,9 @@ export function MentionNote({
type="button"
onClick={(e) => {
e.stopPropagation();
ark.open_event_id(data.id);
LumeWindow.openEvent(data);
}}
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-neutral-100 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-black/10 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
View post
<LinkIcon className="size-4" />

View File

@@ -1,24 +1,20 @@
import { useProfile } from "@lume/ark";
import { LumeWindow, useProfile } from "@lume/system";
import { displayNpub } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
export function MentionUser({ pubkey }: { pubkey: string }) {
const { ark } = useRouteContext({ strict: false });
const { isLoading, isError, profile } = useProfile(pubkey);
return (
<button
type="button"
onClick={() => ark.open_profile(pubkey)}
onClick={() => LumeWindow.openProfile(pubkey)}
className="break-words text-start text-blue-500 hover:text-blue-600"
>
{isLoading
? "@anon"
: isError
? displayNpub(pubkey, 16)
: `@${
profile?.name || profile?.display_name || profile?.name || "anon"
}`}
: `@${profile?.name || profile?.display_name || "anon"}`}
</button>
);
}

View File

@@ -1,37 +1,28 @@
import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useRouteContext } from "@tanstack/react-router";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "./provider";
import { LumeWindow } from "@lume/system";
export function NoteMenu() {
const { t } = useTranslation();
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const copyID = async () => {
await writeText(await ark.event_to_bech32(event.id, [""]));
toast.success("Copied");
await writeText(await event.idAsBech32());
};
const copyRaw = async () => {
await writeText(JSON.stringify(event));
toast.success("Copied");
};
const copyNpub = async () => {
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
toast.success("Copied");
await writeText(await event.pubkeyAsBech32());
};
const copyLink = async () => {
await writeText(
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
toast.success("Copied");
await writeText(`https://njump.me/${await event.idAsBech32()}`);
};
return (
@@ -49,7 +40,7 @@ export function NoteMenu() {
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_event(event)}
onClick={() => LumeWindow.openEvent(event)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewThread")}
@@ -84,7 +75,7 @@ export function NoteMenu() {
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
onClick={() => ark.open_profile(event.pubkey)}
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewAuthor")}

View File

@@ -1,19 +1,25 @@
import type { Event } from "@lume/types";
import { LumeEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react";
const EventContext = createContext<Event>(null);
const NoteContext = createContext<LumeEvent>(null);
export function NoteProvider({
event,
children,
}: { event: Event; children: ReactNode }) {
}: {
event: NostrEvent;
children: ReactNode;
}) {
const lumeEvent = new LumeEvent(event);
return (
<EventContext.Provider value={event}>{children}</EventContext.Provider>
<NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
);
}
export function useNoteContext() {
const context = useContext(EventContext);
const context = useContext(NoteContext);
if (!context) {
throw new Error("Please import Note Provider to use useNoteContext() hook");
}

View File

@@ -1,11 +1,10 @@
import { cn } from "@lume/utils";
import * as HoverCard from "@radix-ui/react-hover-card";
import { useRouteContext } from "@tanstack/react-router";
import { User } from "../user";
import { useNoteContext } from "./provider";
import { LumeWindow } from "@lume/system";
export function NoteUser({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
return (
@@ -46,7 +45,7 @@ export function NoteUser({ className }: { className?: string }) {
</div>
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
<button
onClick={() => ark.open_profile(event.pubkey)}
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
View profile

View File

@@ -1,4 +1,4 @@
import type { Event } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
@@ -6,7 +6,7 @@ export function Notification({
event,
className,
}: {
event: Event;
event: NostrEvent;
className?: string;
}) {
return (

View File

@@ -1,5 +1,5 @@
import { QuoteIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
@@ -7,7 +7,7 @@ export function Quote({
event,
className,
}: {
event: Event;
event: NostrEvent;
className?: string;
}) {
const quoteEventId = event.tags.find(

View File

@@ -1,19 +1,18 @@
import type { Event } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { User } from "@/components/user";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { NostrEvent } from "@lume/types";
import { NostrQuery } from "@lume/system";
export function RepostNote({
event,
className,
}: {
event: Event;
event: NostrEvent;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const {
isLoading,
isError,
@@ -23,12 +22,12 @@ export function RepostNote({
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed: Event = JSON.parse(event.content);
const embed: NostrEvent = JSON.parse(event.content);
return embed;
}
const id = event.tags.find((el) => el[0] === "e")?.[1];
const repostEvent = await ark.get_event(id);
const repostEvent = await NostrQuery.getEvent(id);
return repostEvent;
} catch (e) {

View File

@@ -1,4 +1,4 @@
import type { Event } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import { cn } from "@lume/utils";
import { Note } from "@/components/note";
@@ -6,7 +6,7 @@ export function TextNote({
event,
className,
}: {
event: Event;
event: NostrEvent;
className?: string;
}) {
return (

View File

@@ -1,9 +1,9 @@
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@lume/ui";
import { useUserContext } from "./provider";
import { NostrAccount } from "@lume/system";
export function UserFollowButton({
simple = false,
@@ -12,7 +12,6 @@ export function UserFollowButton({
simple?: boolean;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const user = useUserContext();
const [t] = useTranslation();
@@ -22,10 +21,10 @@ export function UserFollowButton({
const toggleFollow = async () => {
setLoading(true);
if (!followed) {
const add = await ark.follow(user.pubkey);
const add = await NostrAccount.follow(user.pubkey, user.profile?.name);
if (add) setFollowed(true);
} else {
const remove = await ark.unfollow(user.pubkey);
const remove = await NostrAccount.unfollow(user.pubkey);
if (remove) setFollowed(false);
}
setLoading(false);
@@ -35,7 +34,7 @@ export function UserFollowButton({
async function status() {
setLoading(true);
const contacts = await ark.get_contact_list();
const contacts = await NostrAccount.getContactList();
if (contacts?.includes(user.pubkey)) {
setFollowed(true);
}

View File

@@ -2,21 +2,21 @@ import { VerifiedIcon } from "@lume/icons";
import { displayLongHandle, displayNpub } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { useUserContext } from "./provider";
import { NostrQuery } from "@lume/system";
export function UserNip05() {
const user = useUserContext();
const { ark } = useRouteContext({ strict: false });
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.pubkey],
queryFn: async () => {
if (!user.profile?.nip05) return false;
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
const verify = await NostrQuery.verifyNip05(
user.pubkey,
user.profile?.nip05,
);
return verify;
},
enabled: !!user.profile,
enabled: !!user.profile?.nip05,
});
if (!user.profile?.nip05?.length) return;

View File

@@ -1,12 +1,12 @@
import { useProfile } from "@lume/ark";
import { useProfile } from "@lume/system";
import type { Metadata } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react";
const UserContext = createContext<{
pubkey: string;
profile: Metadata;
isError: boolean;
isLoading: boolean;
profile: Metadata;
}>(null);
export function UserProvider({
@@ -21,7 +21,7 @@ export function UserProvider({
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
return (
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
{children}
</UserContext.Provider>
);

View File

@@ -1,44 +1,28 @@
import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { EventColumns, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { VList, type VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({
loader: async ({ context }) => {
try {
const userColumns = await context.ark.get_columns();
if (userColumns.length > 0) {
return userColumns;
} else {
const systemPath = "resources/system_columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
return systemColumns;
}
} catch (e) {
console.error(String(e));
}
loader: async () => {
const columns = NostrQuery.getColumns();
return columns;
},
component: Screen,
});
function Screen() {
const userSavedColumns = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams();
const { ark } = Route.useRouteContext();
const initialColumnList = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [columns, setColumns] = useState([]);
@@ -115,12 +99,12 @@ function Screen() {
);
useEffect(() => {
setColumns(userSavedColumns);
}, [userSavedColumns]);
setColumns(initialColumnList);
}, [initialColumnList]);
useEffect(() => {
// save state
ark.set_columns(columns);
NostrQuery.setColumns(columns);
}, [columns]);
useEffect(() => {
@@ -148,11 +132,10 @@ function Screen() {
horizontal
tabIndex={-1}
itemSize={440}
overscan={3}
overscan={5}
onScroll={() => setIsScroll(true)}
onScrollEnd={() => setIsScroll(false)}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
cache={null}
>
{columns.map((column) => (
<Column

View File

@@ -5,7 +5,7 @@ import {
PlusIcon,
SearchIcon,
} from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { type NostrEvent, Kind } from "@lume/types";
import { User } from "@/components/user";
import {
cn,
@@ -19,20 +19,24 @@ import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import * as Popover from "@radix-ui/react-popover";
import { LumeWindow, NostrAccount, NostrQuery } from "@lume/system";
import { Link } from "@tanstack/react-router";
type AccountSearch = {
accounts?: string[];
};
export const Route = createFileRoute("/$account")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const accounts = await ark.get_accounts();
return { accounts };
validateSearch: (search: Record<string, unknown>): AccountSearch => {
return {
accounts: (search?.accounts as string[]) || [],
};
},
component: Screen,
});
function Screen() {
const { ark, platform } = Route.useRouteContext();
const navigate = Route.useNavigate();
const { platform } = Route.useRouteContext();
return (
<div className="flex h-screen w-screen flex-col">
@@ -45,18 +49,17 @@ function Screen() {
>
<div className="flex items-center gap-3">
<Accounts />
<button
type="button"
onClick={() => navigate({ to: "/landing/" })}
<Link
to="/landing/"
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
>
<PlusIcon className="size-5" />
</button>
</Link>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => ark.open_editor()}
onClick={() => LumeWindow.openEditor()}
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
<ComposeFilledIcon className="size-4" />
@@ -65,7 +68,7 @@ function Screen() {
<Bell />
<button
type="button"
onClick={() => ark.open_search()}
onClick={() => LumeWindow.openSearch()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<SearchIcon className="size-5" />
@@ -81,7 +84,7 @@ function Screen() {
}
function Accounts() {
const { ark, accounts } = Route.useRouteContext();
const { accounts } = Route.useSearch();
const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null);
@@ -102,11 +105,11 @@ function Accounts() {
const changeAccount = async (npub: string) => {
if (npub === account) {
return await ark.open_profile(account);
return await LumeWindow.openProfile(account);
}
// change current account and update signer
const select = await ark.load_account(npub);
const select = await NostrAccount.loadAccount(npub);
if (select) {
return navigate({ to: "/$account/home", params: { account: npub } });
@@ -190,9 +193,7 @@ function Accounts() {
}
function Bell() {
const { ark } = Route.useRouteContext();
const { account } = Route.useParams();
const [count, setCount] = useState(0);
useEffect(() => {
@@ -202,8 +203,8 @@ function Bell() {
setCount((prevCount) => prevCount + 1);
await invoke("set_badge", { count });
const event: Event = JSON.parse(payload.payload);
const user = await ark.get_profile(event.pubkey);
const event: NostrEvent = JSON.parse(payload.payload);
const user = await NostrQuery.getProfile(event.pubkey);
const userName =
user.display_name || user.name || displayNpub(event.pubkey, 16);
@@ -240,7 +241,7 @@ function Bell() {
type="button"
onClick={() => {
setCount(0);
ark.open_activity(account);
LumeWindow.openActivity(account);
}}
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>

View File

@@ -1,35 +1,21 @@
import type { Ark } from "@lume/ark";
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
import type { Interests, Metadata, Settings } from "@lume/types";
import type { Settings } from "@lume/types";
import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os";
import type { Descendant } from "slate";
import { Toaster } from "sonner";
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
interface RouterContext {
// System
ark: Ark;
queryClient: QueryClient;
// App info
platform?: Platform;
locale?: string;
// Settings
settings?: Settings;
interests?: Interests;
// Profile
// Accounts
accounts?: string[];
profile?: Metadata;
isNewUser?: boolean;
// Editor
initialValue?: EditorElement[];
}
export const Route = createRootRouteWithContext<RouterContext>()({

View File

@@ -3,11 +3,11 @@ import { Note } from "@/components/note";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
import { NostrQuery } from "@lume/system";
export const Route = createFileRoute("/activity/$account/texts")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "1")) };
loader: async ({ params }) => {
return { data: defer(NostrQuery.getUserActivities(params.account, "1")) };
},
component: Screen,
});

View File

@@ -11,7 +11,7 @@ function Screen() {
const { account } = Route.useParams();
return (
<Container withDrag withNavigate={false}>
<Container withDrag>
<Box className="scrollbar-none shadow-none bg-black/5 dark:bg-white/5 backdrop-blur-sm flex flex-col overflow-y-auto">
<div className="h-14 shrink-0 flex w-full items-center gap-1 px-3">
<div className="inline-flex h-full w-full items-center gap-1">

View File

@@ -1,4 +1,5 @@
import { User } from "@/components/user";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { decodeZapInvoice } from "@lume/utils";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
@@ -6,9 +7,10 @@ import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/zaps")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "9735")) };
loader: async ({ params }) => {
return {
data: defer(NostrQuery.getUserActivities(params.account, "9735")),
};
},
component: Screen,
});

View File

@@ -1,5 +1,4 @@
import { CheckIcon } from "@lume/icons";
import type { AppRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { displayNsec } from "@lume/utils";
import * as Checkbox from "@radix-ui/react-checkbox";
@@ -10,17 +9,12 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/backup")({
validateSearch: (search: Record<string, string>): AppRouteSearch => {
return {
account: search.account,
};
},
export const Route = createFileRoute("/auth/$account/backup")({
component: Screen,
});
function Screen() {
const { account } = Route.useSearch();
const { account } = Route.useParams();
const { t } = useTranslation();
const [key, setKey] = useState(null);
@@ -40,7 +34,6 @@ function Screen() {
return navigate({
to: "/auth/settings",
search: { account },
});
}

View File

@@ -1,5 +1,6 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
@@ -10,17 +11,17 @@ import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({
component: Screen,
loader: ({ context }) => {
return context.ark.create_keys();
loader: async () => {
const account = await NostrAccount.createAccount();
return account;
},
});
function Screen() {
const keys = Route.useLoaderData();
const account = Route.useLoaderData();
const navigate = useNavigate();
const { t } = useTranslation();
const { ark } = Route.useRouteContext();
const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>("");
@@ -35,17 +36,17 @@ function Screen() {
try {
// Save account keys
const save = await ark.save_account(keys.nsec);
const save = await NostrAccount.saveAccount(account.nsec);
// Then create profile
if (save) {
const profile: Metadata = { ...data, picture };
const eventId = await ark.create_profile(profile);
const eventId = await NostrAccount.createProfile(profile);
if (eventId) {
navigate({
to: "/auth/new/backup",
search: { account: keys.npub },
to: "/auth/$account/backup",
params: { account: account.npub },
replace: true,
});
}

View File

@@ -1,3 +1,4 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/privkey")({
});
function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate();
const [key, setKey] = useState("");
@@ -24,12 +24,11 @@ function Screen() {
try {
setLoading(true);
const npub = await ark.save_account(key, password);
const npub = await NostrAccount.saveAccount(key, password);
if (npub) {
navigate({
to: "/auth/settings",
search: { account: npub },
replace: true,
});
}

View File

@@ -1,3 +1,4 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
@@ -8,7 +9,6 @@ export const Route = createLazyFileRoute("/auth/remote")({
});
function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate();
const [uri, setUri] = useState("");
@@ -23,12 +23,11 @@ function Screen() {
try {
setLoading(true);
const npub = await ark.nostr_connect(uri);
const npub = await NostrAccount.connectRemoteAccount(uri);
if (npub) {
navigate({
to: "/auth/settings",
search: { account: npub },
replace: true,
});
}

View File

@@ -1,45 +1,31 @@
import { LaurelIcon } from "@lume/icons";
import type { AppRouteSearch, Settings } from "@lume/types";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { createFileRoute } from "@tanstack/react-router";
import { requestPermission } from "@tauri-apps/plugin-notification";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/settings")({
validateSearch: (search: Record<string, string>): AppRouteSearch => {
return {
account: search.account,
};
},
beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark;
const settings = await ark.get_settings();
return {
settings: { ...settings, notification: permissionGranted },
};
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
component: Screen,
pendingComponent: Pending,
});
function Screen() {
const navigate = useNavigate();
const { account } = Route.useSearch();
const { settings } = Route.useRouteContext();
const { t } = useTranslation();
const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings);
const [newSettings, setNewSettings] = useState(settings);
const [loading, setLoading] = useState(false);
const navigate = Route.useNavigate();
const toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
@@ -82,12 +68,11 @@ function Screen() {
setLoading(true);
// publish settings
const eventId = await ark.set_settings(newSettings);
const eventId = await NostrQuery.setSettings(newSettings);
if (eventId) {
return navigate({
to: "/$account/home",
params: { account },
to: "/",
replace: true,
});
}

View File

@@ -1,10 +1,11 @@
import { CancelIcon, CheckCircleIcon, PlusIcon } from "@lume/icons";
import { CancelIcon, PlusIcon } from "@lume/icons";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { User } from "@/components/user";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { NostrAccount, NostrQuery } from "@lume/system";
export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -14,17 +15,14 @@ export const Route = createFileRoute("/create-group")({
name: search.name,
};
},
loader: async ({ context }) => {
const ark = context.ark;
const contacts = await ark.get_contact_list();
loader: async () => {
const contacts = await NostrAccount.getContactList();
return contacts;
},
component: Screen,
});
function Screen() {
const { ark } = Route.useRouteContext();
const [title, setTitle] = useState("");
const [npub, setNpub] = useState("");
const [users, setUsers] = useState<string[]>([
@@ -57,7 +55,10 @@ function Screen() {
setIsLoading(true);
const key = `lume_group_${search.label}`;
const createGroup = await ark.set_nstore(key, JSON.stringify(users));
const createGroup = await NostrQuery.setNstore(
key,
JSON.stringify(users),
);
if (createGroup) {
return navigate({ to: search.redirect, search: { ...search } });

View File

@@ -1,7 +1,7 @@
import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
@@ -30,7 +30,7 @@ function Screen() {
try {
setIsLoading(true);
const sync: boolean = await invoke("friend_to_friend", { npub });
const sync = await NostrAccount.f2f(npub);
if (sync) {
return navigate({ to: redirect });

View File

@@ -5,6 +5,7 @@ import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
import { toast } from "sonner";
import type { ColumnRouteSearch } from "@lume/types";
import { NostrAccount } from "@lume/system";
export const Route = createFileRoute("/create-newsfeed/users")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -31,7 +32,6 @@ export const Route = createFileRoute("/create-newsfeed/users")({
});
function Screen() {
const { ark } = Route.useRouteContext();
const { data } = Route.useLoaderData();
const { redirect } = Route.useSearch();
@@ -52,7 +52,7 @@ function Screen() {
try {
setIsLoading(true);
const newContactList = await ark.set_contact_list(follows);
const newContactList = await NostrAccount.setContactList(follows);
if (newContactList) {
return navigate({ to: redirect });

View File

@@ -1,4 +1,5 @@
import { CheckCircleIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch, Topic } from "@lume/types";
import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils";
@@ -18,8 +19,6 @@ export const Route = createFileRoute("/create-topic")({
});
function Screen() {
const { ark } = Route.useRouteContext();
const [topics, setTopics] = useState<Topic[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -39,7 +38,10 @@ function Screen() {
setIsLoading(true);
const key = `lume_topic_${search.label}`;
const createTopic = await ark.set_nstore(key, JSON.stringify(topics));
const createTopic = await NostrQuery.setNstore(
key,
JSON.stringify(topics),
);
if (createTopic) {
return navigate({ to: search.redirect, search: { ...search } });

View File

@@ -1,8 +1,8 @@
import { AddMediaIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn, insertImage, isImagePath } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
@@ -11,15 +11,14 @@ import { toast } from "sonner";
export function MediaButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => {
const upload = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload();
const image = await NostrQuery.upload();
insertImage(editor, image);
// reset loading
@@ -44,7 +43,7 @@ export function MediaButton({ className }: { className?: string }) {
// upload all images
for (const item of items) {
if (isImagePath(item)) {
const image = await ark.upload(item);
const image = await NostrQuery.upload(item);
insertImage(editor, image);
}
}
@@ -67,7 +66,7 @@ export function MediaButton({ className }: { className?: string }) {
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => uploadToNostrBuild()}
onClick={() => upload()}
disabled={loading}
className={cn("inline-flex items-center justify-center", className)}
>

View File

@@ -3,20 +3,19 @@ import { cn, insertMention } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useEffect, useState } from "react";
import { useRouteContext } from "@tanstack/react-router";
import { useSlateStatic } from "slate-react";
import type { Contact } from "@lume/types";
import { toast } from "sonner";
import { User } from "@/components/user";
import { NostrAccount, NostrQuery } from "@lume/system";
export function MentionButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [contacts, setContacts] = useState<string[]>([]);
const select = async (user: string) => {
try {
const metadata = await ark.get_profile(user);
const metadata = await NostrQuery.getProfile(user);
const contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact);
@@ -27,7 +26,7 @@ export function MentionButton({ className }: { className?: string }) {
useEffect(() => {
async function getContacts() {
const data = await ark.get_contact_list();
const data = await NostrAccount.getContactList();
setContacts(data);
}
@@ -61,20 +60,26 @@ export function MentionButton({ className }: { className?: string }) {
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
{contacts.map((contact) => (
<DropdownMenu.Item
key={contact}
onClick={() => select(contact)}
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
>
<User.Provider pubkey={contact}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="shrink-0 size-8 rounded-full" />
<User.Name className="text-sm font-medium text-white dark:text-black" />
</User.Root>
</User.Provider>
</DropdownMenu.Item>
))}
{contacts.length < 1 ? (
<div className="w-full h-full flex items-center justify-center">
<p className="text-sm text-white">Contact List is empty.</p>
</div>
) : (
contacts.map((contact) => (
<DropdownMenu.Item
key={contact}
onClick={() => select(contact)}
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
>
<User.Provider pubkey={contact}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="shrink-0 size-8 rounded-full" />
<User.Name className="text-sm font-medium text-white dark:text-black" />
</User.Root>
</User.Provider>
</DropdownMenu.Item>
))
)}
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</DropdownMenu.Content>
</DropdownMenu.Portal>

View File

@@ -3,13 +3,13 @@ import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react";
export function NsfwToggle({
nsfw,
setNsfw,
export function WarningToggle({
warning,
setWarning,
className,
}: {
nsfw: boolean;
setNsfw: Dispatch<SetStateAction<boolean>>;
warning: boolean;
setWarning: Dispatch<SetStateAction<boolean>>;
className?: string;
}) {
return (
@@ -18,11 +18,11 @@ export function NsfwToggle({
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setNsfw((prev) => !prev)}
onClick={() => setWarning((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
nsfw ? "bg-blue-500 text-white" : "",
warning ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />

View File

@@ -1,4 +1,4 @@
import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
import { ComposeFilledIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import {
cn,
@@ -8,7 +8,6 @@ import {
sendNativeNotification,
} from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type Descendant, Node, Transforms, createEditor } from "slate";
@@ -22,16 +21,22 @@ import {
withReact,
} from "slate-react";
import { MediaButton } from "./-components/media";
import { NsfwToggle } from "./-components/nsfw";
import { MentionButton } from "./-components/mention";
import { LumeEvent } from "@lume/system";
import { WarningToggle } from "./-components/warning";
import { MentionNote } from "@/components/note/mentions/note";
import { toast } from "sonner";
type EditorSearch = {
reply_to: string;
quote: boolean;
};
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => {
return {
@@ -39,43 +44,23 @@ export const Route = createFileRoute("/editor/")({
quote: search.quote === "true" || false,
};
},
beforeLoad: async ({ search }) => {
return {
initialValue: search.quote
? [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
children: [{ text: "" }],
},
{
type: "paragraph",
children: [{ text: "" }],
},
]
: [
{
type: "paragraph",
children: [{ text: "" }],
},
],
};
},
component: Screen,
});
const initialValue: EditorElement[] = [
{
type: "paragraph",
children: [{ text: "" }],
},
];
function Screen() {
const { reply_to, quote } = Route.useSearch();
const { ark, initialValue } = Route.useRouteContext();
const search = Route.useSearch();
const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue);
const [loading, setLoading] = useState(false);
const [nsfw, setNsfw] = useState(false);
const [warning, setWarning] = useState(false);
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
@@ -116,7 +101,12 @@ function Screen() {
setLoading(true);
const content = serialize(editor.children);
const eventId = await ark.publish(content, reply_to, quote);
const eventId = await LumeEvent.publish(
content,
search.reply_to,
search.quote,
warning,
);
if (eventId) {
await sendNativeNotification(
@@ -137,15 +127,15 @@ function Screen() {
};
return (
<div className="w-full h-full">
<div className="w-full h-full flex flex-col">
<Slate editor={editor} initialValue={editorValue}>
<div
data-tauri-drag-region
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
className="shrink-0 flex h-14 w-full items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
>
<NsfwToggle
nsfw={nsfw}
setNsfw={setNsfw}
<WarningToggle
warning={warning}
setWarning={setWarning}
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
/>
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
@@ -163,13 +153,13 @@ function Screen() {
{t("global.post")}
</button>
</div>
<div className="flex h-full w-full flex-1 flex-col">
{reply_to && !quote ? (
<div className="flex-1 overflow-y-auto flex flex-col">
{search.reply_to ? (
<div className="px-4 py-2">
<MentionNote eventId={reply_to} />
<MentionNote eventId={search.reply_to} />
</div>
) : null}
<div className="overflow-y-auto p-4">
<div className="overflow-y-auto scrollbar-none p-4">
<Editable
key={JSON.stringify(editorValue)}
autoFocus={true}
@@ -178,7 +168,7 @@ function Screen() {
spellCheck={false}
renderElement={(props) => <Element {...props} />}
placeholder={
reply_to ? "Type your reply..." : t("editor.placeholder")
search.reply_to ? "Type your reply..." : t("editor.placeholder")
}
className="focus:outline-none"
/>
@@ -252,35 +242,24 @@ const withImages = (editor: ReactEditor) => {
return editor;
};
const Image = ({ attributes, children, element }) => {
const Image = ({ attributes, element, children }) => {
const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected();
const focused = useFocused();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return (
<div {...attributes}>
{children}
<div contentEditable={false} className="relative my-2">
<img
src={element.url}
alt={element.url}
className={cn(
"h-auto w-full rounded-lg border border-neutral-100 object-cover ring-2 dark:border-neutral-900",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
contentEditable={false}
/>
<button
type="button"
contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="absolute right-2 top-2 inline-flex size-8 items-center justify-center rounded-lg bg-red-500 text-white hover:bg-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
<img
src={element.url}
alt={element.url}
className={cn(
"my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
selected && focused ? "ring-blue-500" : "ring-transparent",
)}
onClick={() => Transforms.removeNodes(editor, { at: path })}
/>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { useEvent } from "@lume/ark";
import type { Event } from "@lume/types";
import { NostrQuery, useEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { Box, Container, Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { createFileRoute } from "@tanstack/react-router";
@@ -7,10 +7,8 @@ import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
component: Screen,
@@ -52,7 +50,7 @@ function Screen() {
);
}
function MainNote({ data }: { data: Event }) {
function MainNote({ data }: { data: NostrEvent }) {
return (
<Note.Provider event={data}>
<Note.Root>

View File

@@ -1,10 +1,10 @@
import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Reply } from "./reply";
import { LumeEvent } from "@lume/system";
export function ReplyList({
eventId,
@@ -13,13 +13,12 @@ export function ReplyList({
eventId: string;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => {
async function getReplies() {
const events = await ark.get_event_thread(eventId);
const events = await LumeEvent.getReplies(eventId);
setData(events);
}
getReplies();

View File

@@ -1,7 +1,12 @@
import type { Event } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
export function SubReply({ event }: { event: Event; rootEventId?: string }) {
export function SubReply({
event,
}: {
event: NostrEvent;
rootEventId?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root>

View File

@@ -3,7 +3,8 @@ import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
@@ -17,10 +18,8 @@ export const Route = createFileRoute("/global")({
name: search.name,
};
},
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
component: Screen,
@@ -28,7 +27,6 @@ export const Route = createFileRoute("/global")({
export function Screen() {
const { label, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const {
data,
isLoading,
@@ -40,7 +38,7 @@ export function Screen() {
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_global_events(20, pageParam);
const events = await NostrQuery.getGlobalEvents(pageParam);
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
@@ -48,7 +46,7 @@ export function Screen() {
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:

View File

@@ -3,7 +3,8 @@ import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
@@ -17,11 +18,10 @@ export const Route = createFileRoute("/group")({
name: search.name,
};
},
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
beforeLoad: async ({ search }) => {
const key = `lume_group_${search.label}`;
const groups = (await ark.get_nstore(key)) as string[];
const settings = await ark.get_settings();
const groups = (await NostrQuery.getNstore(key)) as string[];
const settings = await NostrQuery.getSettings();
if (!groups?.length) {
throw redirect({
@@ -43,7 +43,7 @@ export const Route = createFileRoute("/group")({
export function Screen() {
const { label, account } = Route.useSearch();
const { ark, groups } = Route.useRouteContext();
const { groups } = Route.useRouteContext();
const {
data,
isLoading,
@@ -55,7 +55,7 @@ export function Screen() {
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_local_events(groups, 20, pageParam);
const events = await NostrQuery.getLocalEvents(groups, pageParam);
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
@@ -64,7 +64,7 @@ export function Screen() {
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:

View File

@@ -4,28 +4,22 @@ import { User } from "@/components/user";
import { checkForAppUpdates } from "@lume/utils";
import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
import { NostrAccount } from "@lume/system";
export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => {
// check for app updates
await checkForAppUpdates(true);
beforeLoad: async () => {
await checkForAppUpdates(true); // check for app updates
const accounts = await NostrAccount.getAccounts();
const ark = context.ark;
const accounts = await ark.get_accounts();
if (!accounts.length) {
if (accounts.length < 1) {
throw redirect({
to: "/landing/",
to: "/landing",
replace: true,
});
}
// Run notification service
await invoke("run_notification", { accounts });
return { accounts };
},
component: Screen,
@@ -33,7 +27,7 @@ export const Route = createFileRoute("/")({
function Screen() {
const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext();
const context = Route.useRouteContext();
const [loading, setLoading] = useState(false);
@@ -41,11 +35,15 @@ function Screen() {
try {
setLoading(true);
const loadAccount = await ark.load_account(npub);
if (loadAccount) {
const status = await NostrAccount.loadAccount(npub);
if (status) {
return navigate({
to: "/$account/home",
params: { account: npub },
search: {
accounts: context.accounts,
},
replace: true,
});
}
@@ -64,60 +62,45 @@ function Screen() {
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="relative z-20 flex flex-col items-center gap-16">
<div className="text-center text-white">
<h2 className="mb-1 text-2xl">{currentDate}</h2>
<div className="text-center">
<h2 className="text-xl text-neutral-700 dark:text-neutral-300">
{currentDate}
</h2>
<h2 className="text-2xl font-semibold">Welcome back!</h2>
</div>
<div className="flex flex-wrap px-3 items-center justify-center gap-6">
{loading ? (
<div className="inline-flex size-6 items-center justify-center">
<Spinner className="size-6 text-white" />
<Spinner className="size-6" />
</div>
) : (
<>
{accounts.map((account) => (
{context.accounts.map((account) => (
<button
type="button"
key={account}
type="button"
onClick={() => select(account)}
>
<User.Provider pubkey={account}>
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 hover:bg-white/10 dark:hover:bg-black/10">
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
<User.Avatar className="size-20 rounded-full object-cover" />
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" />
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
</User.Root>
</User.Provider>
</button>
))}
<Link to="/landing/">
<div className="flex h-36 w-32 flex-col items-center justify-center gap-4 rounded-2xl p-2 text-white hover:bg-white/10 dark:hover:bg-black/10">
<div className="flex size-20 items-center justify-center rounded-full bg-white/20 dark:bg-black/20">
<Link to="/landing">
<div className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
<div className="flex size-20 items-center justify-center rounded-full bg-black/5 dark:bg-white/5">
<PlusIcon className="size-8" />
</div>
<p className="text-lg font-medium leading-tight">Add</p>
<p className="font-medium leading-tight">Add</p>
</div>
</Link>
</>
)}
</div>
</div>
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { KeyIcon, RemoteIcon } from "@lume/icons";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/landing")({
component: Screen,
});
function Screen() {
return (
<div
data-tauri-drag-region
className="flex flex-col justify-center items-center h-screen w-screen"
>
<div className="mx-auto max-w-xs lg:max-w-md w-full">
<div className="flex w-full flex-col gap-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50 px-2">
<div className="h-20 flex items-center border-b border-neutral-100 dark:border-white/5">
<Link
to="/auth/new/profile"
className="h-14 w-full flex items-center justify-center gap-2 hover:bg-neutral-100 dark:hover:bg-white/10 rounded-lg px-2"
>
<div className="size-9 shrink-0 rounded-full inline-flex items-center justify-center">
<img
src="/icon.jpeg"
alt="App Icon"
className="size-9 object-cover rounded-full"
/>
</div>
<div className="flex-1 inline-flex flex-col">
<span className="leading-tight font-semibold">
Create new account
</span>
<span className="leading-tight text-sm text-neutral-500">
Use everywhere
</span>
</div>
</Link>
</div>
<div className="flex flex-col gap-1 pb-2.5">
<Link
to="/auth/privkey"
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="size-9 inline-flex items-center justify-center">
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Login with Private Key
</Link>
<Link
to="/auth/remote"
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="size-9 inline-flex items-center justify-center">
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Nostr Connect
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { KeyIcon, RemoteIcon } from "@lume/icons";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/landing/")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
return (
<div className="relative flex h-screen w-screen bg-black">
<div
data-tauri-drag-region
className="absolute left-0 top-0 z-50 h-16 w-full"
/>
<div className="z-20 flex h-full w-full flex-col items-center justify-between">
<div />
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-10">
<div className="flex flex-col items-center text-center">
<img
src="/heading-en.png"
srcSet="/heading-en@2x.png 2x"
alt="lume"
className="xl:w-2/3"
/>
<p className="mt-4 whitespace-pre-line text-lg font-medium leading-snug text-white/70">
{t("welcome.title")}
</p>
</div>
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<Link
to="/auth/new/profile"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
>
{t("welcome.signup")}
</Link>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-white/20" />
<div className="text-white/70">{t("login.or")}</div>
<div className="h-px flex-1 bg-white/20" />
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/remote"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
>
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Nostr Connect
<div className="size-5" />
</Link>
<Link
to="/auth/privkey"
className="group inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-white/20 px-3 font-medium text-white backdrop-blur-md hover:bg-white/40"
>
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400 group-hover:text-neutral-400 dark:group-hover:text-neutral-600" />
Private Key
<div className="size-5" />
</Link>
</div>
</div>
</div>
<div className="flex h-11 items-center justify-center" />
</div>
<div className="absolute z-10 h-full w-full bg-black/5 backdrop-blur-sm" />
<div className="absolute inset-0 h-full w-full">
<img
src="/lock-screen.jpg"
srcSet="/lock-screen@2x.jpg 2x"
alt="Lock Screen Background"
className="h-full w-full object-cover"
/>
<a
href="https://njump.me/nprofile1qqs9tuz9jpn57djg7nxunhyvuvk69g5zqaxdpvpqt9hwqv7395u9rpg6zq5uw"
target="_blank"
className="absolute bottom-3 right-3 z-50 rounded-md bg-white/20 px-2 py-1 text-xs font-medium text-white backdrop-blur-md dark:bg-black/20"
rel="noreferrer"
>
Design by NoGood
</a>
</div>
</div>
);
}

View File

@@ -2,11 +2,12 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types";
import { ArrowRightCircleIcon } from "@lume/icons";
import { NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, redirect } from "@tanstack/react-router";
import { redirect } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
@@ -18,10 +19,9 @@ export const Route = createFileRoute("/newsfeed")({
name: search.name,
};
},
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
const contacts = await ark.get_contact_list();
beforeLoad: async ({ search }) => {
const settings = await NostrQuery.getSettings();
const contacts = await NostrAccount.getContactList();
if (!contacts.length) {
throw redirect({
@@ -40,7 +40,7 @@ export const Route = createFileRoute("/newsfeed")({
export function Screen() {
const { label, account } = Route.useSearch();
const { ark, contacts } = Route.useRouteContext();
const { contacts, settings } = Route.useRouteContext();
const {
data,
isLoading,
@@ -52,7 +52,7 @@ export function Screen() {
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_local_events(contacts, 20, pageParam);
const events = await NostrQuery.getLocalEvents(contacts, pageParam);
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
@@ -60,7 +60,7 @@ export function Screen() {
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
@@ -72,7 +72,14 @@ export function Screen() {
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
return (
<Conversation
key={event.id}
className="mb-3"
event={event}
gossip={settings?.gossip}
/>
);
}
if (isQuote) {

View File

@@ -1,4 +1,5 @@
import { ZapIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
@@ -8,18 +9,16 @@ export const Route = createLazyFileRoute("/nwc")({
});
function Screen() {
const { ark } = Route.useRouteContext();
const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false);
const save = async () => {
const nwc = await ark.set_nwc(uri);
const nwc = await NostrAccount.setWallet(uri);
setIsDone(nwc);
};
return (
<Container withDrag withNavigate={false}>
<Container withDrag>
<div className="h-full w-full flex-1 px-5">
{!isDone ? (
<>

View File

@@ -34,6 +34,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
preload="none"
poster="/poster_1.jpeg"
>
<source
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4"
@@ -55,6 +57,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
preload="none"
poster="/poster_2.jpeg"
>
<source
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4"
@@ -76,6 +80,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls
muted
preload="none"
poster="/poster_3.jpeg"
>
<source
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
@@ -97,6 +103,8 @@ function Screen() {
className="h-auto w-full rounded-lg shadow-md transform"
controls
muted
preload="none"
poster="/poster_4.jpeg"
>
<source
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"

View File

@@ -1,5 +1,5 @@
import { SearchIcon } from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { type NostrEvent, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { User } from "@/components/user";
@@ -7,6 +7,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { LumeWindow } from "@lume/system";
export const Route = createFileRoute("/search")({
component: Screen,
@@ -14,7 +15,7 @@ export const Route = createFileRoute("/search")({
function Screen() {
const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<Event[]>([]);
const [events, setEvents] = useState<NostrEvent[]>([]);
const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500);
@@ -25,7 +26,7 @@ function Screen() {
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query);
const content = await res.json();
const events = content.data as Event[];
const events = content.data as NostrEvent[];
const sorted = events.sort((a, b) => b.created_at - a.created_at);
setLoading(false);
@@ -102,14 +103,12 @@ function Screen() {
);
}
function SearchUser({ event }: { event: Event }) {
const { ark } = Route.useRouteContext();
function SearchUser({ event }: { event: NostrEvent }) {
return (
<button
key={event.id}
type="button"
onClick={() => ark.open_profile(event.pubkey)}
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
>
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
@@ -125,7 +124,7 @@ function SearchUser({ event }: { event: Event }) {
);
}
function SearchNote({ event }: { event: Event }) {
function SearchNote({ event }: { event: NostrEvent }) {
return (
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<Note.Provider event={event}>

View File

@@ -1,5 +1,5 @@
import { User } from "@/components/user";
import type { Account } from "@lume/types";
import { NostrAccount } from "@lume/system";
import { displayNsec } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
@@ -7,19 +7,20 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useState } from "react";
import { toast } from "sonner";
interface Account {
npub: string;
nsec: string;
}
export const Route = createFileRoute("/settings/backup")({
component: Screen,
loader: async ({ context }) => {
const ark = context.ark;
const npubs = await ark.get_accounts();
loader: async () => {
const npubs = await NostrAccount.getAccounts();
const accounts: Account[] = [];
for (const account of npubs) {
const nsec: string = await invoke("get_stored_nsec", {
npub: account.npub,
});
accounts.push({ ...account, nsec });
for (const npub of npubs) {
const nsec: string = await invoke("get_stored_nsec", { npub });
accounts.push({ npub, nsec });
}
return accounts;
@@ -33,14 +34,14 @@ function Screen() {
<div className="mx-auto w-full max-w-xl">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account) => (
<NostrAccount key={account.npub} account={account} />
<List key={account.npub} account={account} />
))}
</div>
</div>
);
}
function NostrAccount({ account }: { account: Account }) {
function List({ account }: { account: Account }) {
const [key, setKey] = useState(account.nsec);
const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState("");

View File

@@ -1,28 +1,22 @@
import { NostrQuery } from "@lume/system";
import type { Settings } from "@lume/types";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router";
import {
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { invoke } from "@tauri-apps/api/core";
import { requestPermission } from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
export const Route = createFileRoute("/settings/general")({
beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark;
const settings = await ark.get_settings();
return {
settings: { ...settings, notification: permissionGranted },
};
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { ark, settings } = Route.useRouteContext();
const { settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings);
const toggleNofitication = async () => {
@@ -33,6 +27,13 @@ function Screen() {
}));
};
const toggleGossip = async () => {
setNewSettings((prev) => ({
...prev,
gossip: !newSettings.gossip,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
@@ -61,8 +62,21 @@ function Screen() {
}));
};
const changeTheme = (theme: string) => {
if (theme === "auto" || theme === "light" || theme === "dark") {
invoke("plugin:theme|set_theme", {
theme: theme,
}).then(() =>
setNewSettings((prev) => ({
...prev,
theme,
})),
);
}
};
const updateSettings = useDebouncedCallback(() => {
ark.set_settings(newSettings);
NostrQuery.setSettings(newSettings);
}, 200);
useEffect(() => {
@@ -95,6 +109,24 @@ function Screen() {
</Switch.Root>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Relay Hint</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically connect to the necessary relay suggested by
Relay Hint when fetching a new event.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.gossip}
onClick={() => toggleGossip()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Enhanced Privacy</h3>
@@ -155,24 +187,42 @@ function Screen() {
Interface
</h2>
<div className="flex flex-col divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl px-3">
<div className="flex flex-col gap-4">
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen,
use for send bitcoin tip to other users.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen,
use for send bitcoin tip to other users.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Appearance</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
* Require restarting the app to take effect.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<select
name="theme"
className="bg-transparent shadow-none outline-none rounded-lg border-1 border-black/10 dark:border-white/10 py-1 w-24"
defaultValue={settings.theme}
onChange={(e) => changeTheme(e.target.value)}
>
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
</div>

View File

@@ -1,14 +1,13 @@
import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/relay")({
loader: async ({ context }) => {
const ark = context.ark;
const relays = await ark.get_relays();
loader: async () => {
const relays = await NostrQuery.getRelays();
return relays;
},
component: Screen,
@@ -18,12 +17,11 @@ function Screen() {
const relayList = Route.useLoaderData();
const [relays, setRelays] = useState(relayList.connected);
const { ark } = Route.useRouteContext();
const { register, reset, handleSubmit } = useForm();
const onSubmit = async (data: { url: string }) => {
try {
const add = await ark.add_relay(data.url);
const add = await NostrQuery.connectRelay(data.url);
if (add) {
setRelays((prev) => [...prev, data.url]);
reset();
@@ -56,6 +54,7 @@ function Screen() {
<div>
<button
type="button"
onClick={() => NostrQuery.removeRelay(relay)}
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10"
>
<CancelIcon className="size-4" />

View File

@@ -1,5 +1,6 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Link } from "@tanstack/react-router";
@@ -9,16 +10,15 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/user")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const profile = await ark.get_current_user_profile();
beforeLoad: async () => {
const profile = await NostrAccount.getProfile();
return { profile };
},
component: Screen,
});
function Screen() {
const { ark, profile } = Route.useRouteContext();
const { profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm({ defaultValues: profile });
const [loading, setLoading] = useState(false);
@@ -29,7 +29,7 @@ function Screen() {
setLoading(true);
const newProfile: Metadata = { ...profile, ...data, picture };
await ark.create_profile(newProfile);
await NostrAccount.createProfile(newProfile);
setLoading(false);
} catch (e) {

View File

@@ -3,7 +3,13 @@ import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind, Topic } from "@lume/types";
import { NostrQuery } from "@lume/system";
import {
type ColumnRouteSearch,
type NostrEvent,
Kind,
Topic,
} from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
@@ -17,11 +23,10 @@ export const Route = createFileRoute("/topic")({
name: search.name,
};
},
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
beforeLoad: async ({ search }) => {
const key = `lume_topic_${search.label}`;
const topics = (await ark.get_nstore(key)) as unknown as Topic[];
const settings = await ark.get_settings();
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
const settings = await NostrQuery.getSettings();
if (!topics?.length) {
throw redirect({
@@ -49,7 +54,7 @@ export const Route = createFileRoute("/topic")({
export function Screen() {
const { label, account } = Route.useSearch();
const { ark, hashtags } = Route.useRouteContext();
const { hashtags } = Route.useRouteContext();
const {
data,
isLoading,
@@ -61,7 +66,7 @@ export function Screen() {
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = ark.get_hashtag_events(hashtags, 20, pageParam);
const events = NostrQuery.getHashtagEvents(hashtags, pageParam);
return events;
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
@@ -69,7 +74,7 @@ export function Screen() {
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:

View File

@@ -1,5 +1,5 @@
import { TextNote } from "@/components/text";
import type { Event } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Await, createFileRoute } from "@tanstack/react-router";
import { defer } from "@tanstack/react-router";
@@ -15,7 +15,7 @@ export const Route = createFileRoute("/trending/notes")({
signal: abortController.signal,
})
.then((res) => res.json())
.then((res) => res.notes.map((item) => item.event) as Event[]),
.then((res) => res.notes.map((item) => item.event) as NostrEvent[]),
),
};
} catch (e) {

View File

@@ -1,4 +1,5 @@
import { ArticleIcon, GroupFeedsIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
@@ -12,10 +13,8 @@ export const Route = createFileRoute("/trending")({
name: search.name,
};
},
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
component: Screen,

View File

@@ -6,20 +6,18 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { type Event, Kind } from "@lume/types";
import { type NostrEvent, Kind } from "@lume/types";
import { Suspense } from "react";
import { Await } from "@tanstack/react-router";
import { NostrQuery } from "@lume/system";
export const Route = createFileRoute("/users/$pubkey")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const settings = await ark.get_settings();
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
loader: async ({ params, context }) => {
const ark = context.ark;
return { data: defer(ark.get_events_by(params.pubkey, 50)) };
loader: async ({ params }) => {
return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
},
component: Screen,
});
@@ -28,7 +26,7 @@ function Screen() {
const { pubkey } = Route.useParams();
const { data } = Route.useLoaderData();
const renderItem = (event: Event) => {
const renderItem = (event: NostrEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:

View File

@@ -1,71 +0,0 @@
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
export function EventList({ id }: { id: string }) {
const { ark } = useRouteContext({ strict: false });
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ["events", id],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events_by(id, 20, pageParam);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
return lastEvent ? lastEvent.created_at - 1 : null;
},
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div>
{isLoading ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<Spinner className="size-5" />
</div>
) : !data.length ? (
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" />
<p>Empty newsfeed.</p>
</div>
) : (
data.map((item) => renderItem(item))
)}
<div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || 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"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { useState } from "react";
import CurrencyInput from "react-currency-input-field";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { LumeEvent } from "@lume/system";
const DEFAULT_VALUES = [69, 100, 200, 500];
@@ -16,7 +17,6 @@ export const Route = createLazyFileRoute("/zap/$id")({
function Screen() {
const { t } = useTranslation();
const { ark } = Route.useRouteContext();
const { id } = Route.useParams();
// @ts-ignore, magic !!!
const { pubkey, account } = Route.useSearch();
@@ -31,7 +31,7 @@ function Screen() {
// start loading
setIsLoading(true);
const val = await ark.zap_event(id, amount, message);
const val = await LumeEvent.zap(id, amount, message);
if (val) {
setIsCompleted(true);

View File

@@ -13,7 +13,7 @@
"@astrojs/check": "^0.5.10",
"@astrojs/tailwind": "^5.1.0",
"@fontsource/alice": "^5.0.13",
"astro": "^4.8.3",
"astro": "^4.9.2",
"astro-seo-meta": "^4.1.1",
"astro-seo-schema": "^4.0.2",
"schema-dts": "^1.1.2",

View File

@@ -20,7 +20,7 @@
"node": ">=18"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0-beta.7",
"@tauri-apps/api": "2.0.0-beta.13",
"@tauri-apps/plugin-autostart": "2.0.0-beta.3",
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.1",
"@tauri-apps/plugin-dialog": "2.0.0-beta.3",

View File

@@ -1,902 +0,0 @@
import {
type Event,
type EventWithReplies,
type Interests,
type Keys,
type LumeColumn,
type Metadata,
type Settings,
Relays,
} from "@lume/types";
import { generateContentTags } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import type { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
enum NSTORE_KEYS {
settings = "lume_user_settings",
columns = "lume_user_columns",
}
export class Ark {
public windows: WebviewWindow[];
public settings: Settings;
public accounts: string[];
constructor() {
this.windows = [];
this.settings = undefined;
}
public async get_accounts() {
try {
const cmd: string = await invoke("get_accounts");
const parse = cmd.split(/\s+/).filter((v) => v.startsWith("npub1"));
const accounts = [...new Set(parse)];
if (!this.accounts) {
this.accounts = accounts;
}
return accounts;
} catch (e) {
console.info(String(e));
return [];
}
}
public async load_account(npub: string) {
try {
const cmd: boolean = await invoke("load_account", {
npub,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async nostr_connect(uri: string) {
try {
const remoteKey = uri.replace("bunker://", "").split("?")[0];
const npub: string = await invoke("to_npub", { hex: remoteKey });
if (npub) {
const connect: string = await invoke("nostr_connect", {
npub,
uri,
});
return connect;
}
} catch (e) {
throw new Error(String(e));
}
}
public async create_keys() {
try {
const cmd: Keys = await invoke("create_account");
return cmd;
} catch (e) {
console.error(String(e));
}
}
public async save_account(nsec: string, password = "") {
try {
const cmd: string = await invoke("save_account", {
nsec,
password,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async event_to_bech32(id: string, relays: string[]) {
try {
const cmd: string = await invoke("event_to_bech32", {
id,
relays,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_relays() {
try {
const cmd: Relays = await invoke("get_relays");
return cmd;
} catch (e) {
console.error(String(e));
return null;
}
}
public async add_relay(url: string) {
try {
const relayUrl = new URL(url);
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
const cmd: boolean = await invoke("connect_relay", { relay: relayUrl });
return cmd;
}
} catch (e) {
throw new Error(String(e));
}
}
public async remove_relay(url: string) {
try {
const relayUrl = new URL(url);
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
const cmd: boolean = await invoke("remove_relay", { relay: relayUrl });
return cmd;
}
} catch (e) {
throw new Error(String(e));
}
}
public async get_activities(account: string, kind: "1" | "6" | "9735" = "1") {
try {
const events: Event[] = await invoke("get_activities", { account, kind });
return events;
} catch (e) {
console.error(String(e));
return null;
}
}
public async get_event(id: string) {
try {
const eventId: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const cmd: string = await invoke("get_event", { id: eventId });
const event: Event = JSON.parse(cmd);
return event;
} catch (e) {
console.error(id, String(e));
throw new Error(String(e));
}
}
public async search(content: string, limit: number) {
try {
if (content.length < 1) return [];
const events: Event[] = await invoke("search", {
content: content.trim(),
limit,
});
return events;
} catch (e) {
console.info(String(e));
return [];
}
}
private dedup_events(nostrEvents: Event[]) {
const seens = new Set<string>();
const events = nostrEvents.filter((event) => {
const eTags = event.tags.filter((el) => el[0] === "e");
const ids = eTags.map((item) => item[1]);
const isDup = ids.some((id) => seens.has(id));
// Add found ids to seen list
for (const id of ids) {
seens.add(id);
}
// Filter NSFW event
if (this.settings?.nsfw) {
const wTags = event.tags.filter((t) => t[0] === "content-warning");
const isLewd = wTags.length > 0;
return !isDup && !isLewd;
}
// Filter duplicate event
return !isDup;
});
return events;
}
public async get_local_events(
pubkeys: string[],
limit: number,
asOf?: number,
) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrEvents: Event[] = await invoke("get_local_events", {
pubkeys,
limit,
until,
});
const events = this.dedup_events(nostrEvents);
return events;
} catch (e) {
console.error("[get_local_events] failed", String(e));
return [];
}
}
public async get_global_events(limit: number, asOf?: number) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrEvents: Event[] = await invoke("get_global_events", {
limit,
until,
});
const events = this.dedup_events(nostrEvents);
return events;
} catch (e) {
console.error("[get_global_events] failed", String(e));
return [];
}
}
public async get_hashtag_events(
hashtags: string[],
limit: number,
asOf?: number,
) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrTags = hashtags.map((tag) => tag.replace("#", ""));
const nostrEvents: Event[] = await invoke("get_hashtag_events", {
hashtags: nostrTags,
limit,
until,
});
const events = this.dedup_events(nostrEvents);
return events;
} catch (e) {
console.error("[get_hashtag_events] failed", String(e));
return [];
}
}
public async get_group_events(
contacts: string[],
limit: number,
asOf?: number,
) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrEvents: Event[] = await invoke("get_group_events", {
list: contacts,
limit,
until,
});
const events = this.dedup_events(nostrEvents);
return events;
} catch (e) {
console.error("[get_group_events] failed", String(e));
return [];
}
}
public async get_events_by(pubkey: string, limit: number, asOf?: number) {
try {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrEvents: Event[] = await invoke("get_events_by", {
publicKey: pubkey,
limit,
as_of: until,
});
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
console.error("[get_events_by] failed", String(e));
return [];
}
}
public async publish(
content: string,
reply_to?: string,
quote?: boolean,
nsfw?: boolean,
) {
try {
const g = await generateContentTags(content);
const eventContent = g.content;
const eventTags = g.tags;
if (reply_to) {
const replyEvent = await this.get_event(reply_to);
const relayHint =
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
if (quote) {
eventTags.push(["e", replyEvent.id, relayHint, "mention"]);
eventTags.push(["q", replyEvent.id]);
} else {
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
if (rootEvent) {
eventTags.push([
"e",
rootEvent[1],
rootEvent[2] || relayHint,
"root",
]);
}
eventTags.push(["e", replyEvent.id, relayHint, "reply"]);
eventTags.push(["p", replyEvent.pubkey]);
}
}
if (nsfw) {
eventTags.push(["L", "content-warning"]);
eventTags.push(["l", "reason", "content-warning"]);
eventTags.push(["content-warning", "nsfw"]);
}
const cmd: string = await invoke("publish", {
content: eventContent,
tags: eventTags,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async reply_to(content: string, tags: string[]) {
try {
const cmd: string = await invoke("reply_to", { content, tags });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async repost(id: string, author: string) {
try {
const cmd: string = await invoke("repost", { id, pubkey: author });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_event_thread(id: string) {
try {
const events: EventWithReplies[] = await invoke("get_thread", {
id,
});
if (events.length > 0) {
const replies = new Set();
for (const event of events) {
const tags = event.tags.filter(
(el) => el[0] === "e" && el[1] !== id && el[3] !== "mention",
);
if (tags.length > 0) {
for (const tag of tags) {
const rootIndex = events.findIndex((el) => el.id === tag[1]);
if (rootIndex !== -1) {
const rootEvent = events[rootIndex];
if (rootEvent?.replies) {
rootEvent.replies.push(event);
} else {
rootEvent.replies = [event];
}
replies.add(event.id);
}
}
}
}
const cleanEvents = events.filter((ev) => !replies.has(ev.id));
return cleanEvents;
}
return events;
} catch (e) {
return [];
}
}
public get_thread(tags: string[][], gossip: boolean = false) {
let root: string = null;
let reply: string = null;
// Get all event references from tags, ignore mention
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
if (gossip) {
const relays = tags.filter((el) => el[0] === "e" && el[2]?.length);
if (relays.length >= 1) {
for (const relay of relays) {
if (relay[2]?.length) this.add_relay(relay[2]);
}
}
}
if (events.length === 1) {
root = events[0][1];
}
if (events.length > 1) {
root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1];
reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1];
}
// Fix some rare case when root === reply
if (root && reply && root === reply) {
reply = null;
}
return {
root,
reply,
};
}
public async get_profile(pubkey: string) {
try {
const id = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const cmd: Metadata = await invoke("get_profile", { id });
return cmd;
} catch (e) {
console.error(pubkey, String(e));
return null;
}
}
public async get_current_user_profile() {
try {
const cmd: Metadata = await invoke("get_current_user_profile");
return cmd;
} catch {
return null;
}
}
public async create_profile(profile: Metadata) {
try {
const event: string = await invoke("create_profile", {
name: profile.name || "",
display_name: profile.display_name || "",
displayName: profile.display_name || "",
about: profile.about || "",
picture: profile.picture || "",
banner: profile.banner || "",
nip05: profile.nip05 || "",
lud16: profile.lud16 || "",
website: profile.website || "",
});
return event;
} catch (e) {
throw new Error(String(e));
}
}
public async set_contact_list(pubkeys: string[]) {
try {
const cmd: boolean = await invoke("set_contact_list", { pubkeys });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_contact_list() {
try {
const cmd: string[] = await invoke("get_contact_list");
return cmd;
} catch (e) {
console.error(e);
return [];
}
}
public async follow(id: string, alias?: string) {
try {
const cmd: string = await invoke("follow", { id, alias });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async unfollow(id: string) {
try {
const cmd: string = await invoke("unfollow", { id });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async user_to_bech32(key: string, relays: string[]) {
try {
const cmd: string = await invoke("user_to_bech32", {
key,
relays,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async verify_nip05(pubkey: string, nip05: string) {
try {
const cmd: boolean = await invoke("verify_nip05", {
key: pubkey,
nip05,
});
return cmd;
} catch {
return false;
}
}
public async set_nwc(uri: string) {
try {
const cmd: boolean = await invoke("set_nwc", { uri });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async load_nwc() {
try {
const cmd: boolean = await invoke("load_nwc");
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_balance() {
try {
const cmd: number = await invoke("get_balance");
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async zap_profile(id: string, amount: number, message: string) {
try {
const cmd: boolean = await invoke("zap_profile", { id, amount, message });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async zap_event(id: string, amount: number, message: string) {
try {
const cmd: boolean = await invoke("zap_event", { id, amount, message });
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async upload(filePath?: string) {
const allowExts = [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
];
const selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
// User cancelled action
if (!selected) return null;
try {
const file = await readFile(selected);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (!res.ok) return null;
const json = await res.json();
const content = json.data[0];
return content.url as string;
} catch (e) {
throw new Error(String(e));
}
}
public async get_columns() {
try {
const cmd: string = await invoke("get_nstore", {
key: NSTORE_KEYS.columns,
});
const columns: LumeColumn[] = cmd ? JSON.parse(cmd) : [];
return columns;
} catch {
return [];
}
}
public async set_columns(columns: LumeColumn[]) {
try {
const cmd: string = await invoke("set_nstore", {
key: NSTORE_KEYS.columns,
content: JSON.stringify(columns),
});
return cmd;
} catch (e) {
throw new Error(e);
}
}
public async get_settings() {
try {
if (this.settings) return this.settings;
const cmd: string = await invoke("get_nstore", {
key: NSTORE_KEYS.settings,
});
const settings: Settings = cmd ? JSON.parse(cmd) : null;
this.settings = settings;
return settings;
} catch {
const defaultSettings: Settings = {
autoUpdate: false,
enhancedPrivacy: false,
notification: false,
zap: false,
nsfw: false,
};
this.settings = defaultSettings;
return defaultSettings;
}
}
public async set_settings(settings: Settings) {
try {
const cmd: string = await invoke("set_nstore", {
key: NSTORE_KEYS.settings,
content: JSON.stringify(settings),
});
return cmd;
} catch (e) {
throw new Error(e);
}
}
public async get_nstore(key: string) {
try {
const cmd: string = await invoke("get_nstore", {
key,
});
const parse: string | string[] = cmd ? JSON.parse(cmd) : null;
if (!parse.length) return null;
return parse;
} catch {
return null;
}
}
public async set_nstore(key: string, content: string) {
try {
const cmd: string = await invoke("set_nstore", {
key,
content,
});
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async open_event_id(id: string) {
try {
const label = `event-${id}`;
const url = `/events/${id}`;
await invoke("open_window", {
label,
title: "Thread",
url,
width: 500,
height: 800,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_event(event: Event) {
try {
let root: string = undefined;
let reply: string = undefined;
const eTags = event.tags.filter(
(tag) => tag[0] === "e" || tag[0] === "q",
);
root = eTags.find((el) => el[3] === "root")?.[1];
reply = eTags.find((el) => el[3] === "reply")?.[1];
if (!root) root = eTags[0]?.[1];
if (!reply) reply = eTags[1]?.[1];
const label = `event-${event.id}`;
const url = `/events/${root ?? reply ?? event.id}`;
await invoke("open_window", {
label,
title: "Thread",
url,
width: 500,
height: 800,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_profile(pubkey: string) {
try {
const label = `user-${pubkey}`;
await invoke("open_window", {
label,
title: "Profile",
url: `/users/${pubkey}`,
width: 500,
height: 800,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_editor(reply_to?: string, quote = false) {
try {
let url: string;
if (reply_to) {
url = `/editor?reply_to=${reply_to}&quote=${quote}`;
} else {
url = "/editor";
}
const label = `editor-${reply_to ? reply_to : 0}`;
await invoke("open_window", {
label,
title: "Editor",
url,
width: 560,
height: 340,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_nwc() {
try {
const label = "nwc";
await invoke("open_window", {
label,
title: "Nostr Wallet Connect",
url: "/nwc",
width: 400,
height: 600,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_zap(id: string, pubkey: string, account: string) {
try {
const label = `zap-${id}`;
await invoke("open_window", {
label,
title: "Zap",
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
width: 400,
height: 500,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_settings() {
try {
const label = "settings";
await invoke("open_window", {
label,
title: "Settings",
url: "/settings",
width: 800,
height: 500,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_search() {
try {
const label = "search";
await invoke("open_window", {
label,
title: "Search",
url: "/search",
width: 400,
height: 600,
});
} catch (e) {
throw new Error(String(e));
}
}
public async open_activity(account: string) {
try {
const label = "activity";
await invoke("open_window", {
label,
title: "Activity",
url: `/activity/${account}/texts`,
width: 400,
height: 600,
});
} catch (e) {
throw new Error(String(e));
}
}
}

View File

@@ -1,24 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
export function usePreview(url: string) {
const { isLoading, isError, data } = useQuery({
queryKey: ["url", url],
queryFn: async () => {
try {
const cmd = await invoke("fetch_opg", { url });
console.log(cmd);
return cmd;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
});
return { isLoading, isError, data };
}

View File

@@ -1,3 +0,0 @@
export * from "./ark";
export * from "./hooks/useEvent";
export * from "./hooks/useProfile";

View File

@@ -8,7 +8,7 @@
},
"devDependencies": {
"@lume/tsconfig": "workspace:*",
"@types/react": "^18.3.2",
"@types/react": "^18.3.3",
"typescript": "^5.4.5"
}
}

View File

@@ -1,17 +1,17 @@
{
"name": "@lume/ark",
"name": "@lume/system",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"dependencies": {
"@lume/utils": "workspace:^",
"@tanstack/react-query": "^5.36.0",
"@tanstack/react-query": "^5.40.0",
"react": "^18.3.1"
},
"devDependencies": {
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/react": "^18.3.2",
"@types/react": "^18.3.3",
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,160 @@
import { Metadata } from "@lume/types";
import { commands } from "./commands";
export class NostrAccount {
static async getAccounts() {
const query = await commands.getAccounts();
if (query.status === "ok") {
return query.data;
} else {
return [];
}
}
static async loadAccount(npub: string) {
const query = await commands.loadAccount(npub);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async createAccount() {
const query = await commands.createAccount();
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async createProfile(profile: Metadata) {
const query = await commands.createProfile(
profile.name || "",
profile.display_name || "",
profile.about || "",
profile.picture || "",
profile.banner || "",
profile.nip05 || "",
profile.lud16 || "",
profile.website || "",
);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async saveAccount(nsec: string, password = "") {
const query = await commands.saveAccount(nsec, password);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async connectRemoteAccount(uri: string) {
const remoteKey = uri.replace("bunker://", "").split("?")[0];
const npub = await commands.toNpub(remoteKey);
if (npub.status === "ok") {
const connect = await commands.nostrConnect(npub.data, uri);
if (connect.status === "ok") {
return connect.data;
} else {
throw new Error(connect.error);
}
} else {
throw new Error(npub.error);
}
}
static async setContactList(pubkeys: string[]) {
const query = await commands.setContactList(pubkeys);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async setWallet(uri: string) {
const query = await commands.setNwc(uri);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async getProfile() {
const query = await commands.getCurrentUserProfile();
if (query.status === "ok") {
return JSON.parse(query.data) as Metadata;
} else {
return null;
}
}
static async getBalance() {
const query = await commands.getBalance();
if (query.status === "ok") {
return parseInt(query.data);
} else {
return 0;
}
}
static async getContactList() {
const query = await commands.getContactList();
if (query.status === "ok") {
return query.data;
} else {
return [];
}
}
static async follow(pubkey: string, alias?: string) {
const query = await commands.follow(pubkey, alias);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async unfollow(pubkey: string) {
const query = await commands.unfollow(pubkey);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async f2f(npub: string) {
const query = await commands.friendToFriend(npub);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
}

View File

@@ -0,0 +1,412 @@
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
export const commands = {
async getRelays() : Promise<Result<Relays, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_relays") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectRelay(relay: string) : Promise<Result<boolean, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async removeRelay(relay: string) : Promise<Result<boolean, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAccounts() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_accounts") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async createAccount() : Promise<Result<Account, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("create_account") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async saveAccount(nsec: string, password: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("save_account", { nsec, password }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEncryptedKey(npub: string, password: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_encrypted_key", { npub, password }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async nostrConnect(npub: string, uri: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("nostr_connect", { npub, uri }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async loadAccount(npub: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("load_account", { npub }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async eventToBech32(id: string, relays: string[]) : Promise<Result<string, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id, relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async userToBech32(key: string, relays: string[]) : Promise<Result<string, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("user_to_bech32", { key, relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async toNpub(hex: string) : Promise<Result<string, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("to_npub", { hex }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async verifyNip05(key: string, nip05: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("verify_nip05", { key, nip05 }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getActivities(account: string, kind: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_activities", { account, kind }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getCurrentUserProfile() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_current_user_profile") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getProfile(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getContactList() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setContactList(pubkeys: string[]) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_contact_list", { pubkeys }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async createProfile(name: string, displayName: string, about: string, picture: string, banner: string, nip05: string, lud16: string, website: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("create_profile", { name, displayName, about, picture, banner, nip05, lud16, website }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async follow(id: string, alias: string | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("follow", { id, alias }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async unfollow(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("unfollow", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getNstore(key: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_nstore", { key }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setNstore(key: string, content: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_nstore", { key, content }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setNwc(uri: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_nwc", { uri }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async loadNwc() : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("load_nwc") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getBalance() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_balance") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async zapProfile(id: string, amount: string, message: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async zapEvent(id: string, amount: string, message: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async friendToFriend(npub: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("friend_to_friend", { npub }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEvent(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getReplies(id: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEventsBy(publicKey: string, asOf: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, asOf }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLocalEvents(pubkeys: string[], until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { pubkeys, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getGlobalEvents(until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_global_events", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getHashtagEvents(hashtags: string[], until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async publish(content: string, tags: string[][]) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("publish", { content, tags }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async repost(raw: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("repost", { raw }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async showInFolder(path: string) : Promise<void> {
await TAURI_INVOKE("show_in_folder", { path });
},
async createColumn(label: string, x: number, y: number, width: number, height: number, url: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("create_column", { label, x, y, width, height, url }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async closeColumn(label: string) : Promise<Result<boolean, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async repositionColumn(label: string, x: number, y: number) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reposition_column", { label, x, y }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async resizeColumn(label: string, width: number, height: number) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("resize_column", { label, width, height }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async openWindow(label: string, title: string, url: string, width: number, height: number) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_window", { label, title, url, width, height }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setBadge(count: number) : Promise<void> {
await TAURI_INVOKE("set_badge", { count });
}
}
/** user-defined types **/
export type Account = { npub: string; nsec: string }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
/** tauri-specta globals **/
import { invoke as TAURI_INVOKE } from "@tauri-apps/api/core";
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
type __EventObj__<T> = {
listen: (
cb: TAURI_API_EVENT.EventCallback<T>
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (
cb: TAURI_API_EVENT.EventCallback<T>
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: T extends null
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
export type Result<T, E> =
| { status: "ok"; data: T }
| { status: "error"; error: E };
function __makeEvents__<T extends Record<string, any>>(
mappings: Record<keyof T, string>
) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindow__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindow__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg),
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case "listen":
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case "once":
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case "emit":
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
},
});
},
}
);
}

View File

@@ -0,0 +1,28 @@
import { NostrEvent } from "@lume/types";
export function dedupEvents(nostrEvents: NostrEvent[], nsfw: boolean = false) {
const seens = new Set<string>();
const events = nostrEvents.filter((event) => {
const eTags = event.tags.filter((el) => el[0] === "e");
const ids = eTags.map((item) => item[1]);
const isDup = ids.some((id) => seens.has(id));
// Add found ids to seen list
for (const id of ids) {
seens.add(id);
}
// Filter NSFW event
if (nsfw) {
const wTags = event.tags.filter((t) => t[0] === "content-warning");
const isLewd = wTags.length > 0;
return !isDup && !isLewd;
}
// Filter duplicate event
return !isDup;
});
return events;
}

View File

@@ -0,0 +1,201 @@
import { EventWithReplies, Kind, NostrEvent } from "@lume/types";
import { commands } from "./commands";
import { generateContentTags } from "@lume/utils";
export class LumeEvent {
public id: string;
public pubkey: string;
public created_at: number;
public kind: Kind;
public tags: string[][];
public content: string;
public sig: string;
public relay?: string;
#raw: NostrEvent;
constructor(event: NostrEvent) {
this.#raw = event;
Object.assign(this, event);
}
get mentions() {
return this.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]);
}
static getEventThread(tags: string[][], gossip?: boolean) {
let root: string = null;
let reply: string = null;
// Get all event references from tags, ignore mention
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
if (gossip) {
const relays = tags.filter((el) => el[0] === "e" && el[2]?.length);
if (relays.length >= 1) {
for (const relay of relays) {
if (relay[2]?.length)
commands
.connectRelay(relay[2])
.then(() => console.log("[gossip]: ", relay[2]));
}
}
}
if (events.length === 1) {
root = events[0][1];
}
if (events.length > 1) {
root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1];
reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1];
}
// Fix some rare case when root === reply
if (root && reply && root === reply) {
reply = null;
}
return {
root,
reply,
};
}
static async getReplies(id: string) {
const query = await commands.getReplies(id);
if (query.status === "ok") {
const events = query.data.map(
(item) => JSON.parse(item) as EventWithReplies,
);
if (events.length > 0) {
const replies = new Set();
for (const event of events) {
const tags = event.tags.filter(
(el) => el[0] === "e" && el[1] !== id && el[3] !== "mention",
);
if (tags.length > 0) {
for (const tag of tags) {
const rootIndex = events.findIndex((el) => el.id === tag[1]);
if (rootIndex !== -1) {
const rootEvent = events[rootIndex];
if (rootEvent?.replies) {
rootEvent.replies.push(event);
} else {
rootEvent.replies = [event];
}
replies.add(event.id);
}
}
}
}
return events.filter((ev) => !replies.has(ev.id));
}
return events;
}
}
static async publish(
content: string,
reply_to?: string,
quote?: boolean,
nsfw?: boolean,
) {
const g = await generateContentTags(content);
const eventContent = g.content;
const eventTags = g.tags;
if (reply_to) {
const queryReply = await commands.getEvent(reply_to);
if (queryReply.status === "ok") {
const replyEvent = JSON.parse(queryReply.data) as NostrEvent;
const relayHint =
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
if (quote) {
eventTags.push(["e", replyEvent.id, relayHint, "mention"]);
eventTags.push(["q", replyEvent.id]);
} else {
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
if (rootEvent) {
eventTags.push([
"e",
rootEvent[1],
rootEvent[2] || relayHint,
"root",
]);
}
eventTags.push(["e", replyEvent.id, relayHint, "reply"]);
eventTags.push(["p", replyEvent.pubkey]);
}
}
}
if (nsfw) {
eventTags.push(["L", "content-warning"]);
eventTags.push(["l", "reason", "content-warning"]);
eventTags.push(["content-warning", "nsfw"]);
}
const query = await commands.publish(eventContent, eventTags);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async zap(id: string, amount: number, message: string) {
const query = await commands.zapEvent(id, amount.toString(), message);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
public async idAsBech32() {
const query = await commands.eventToBech32(this.id, []);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
public async pubkeyAsBech32() {
const query = await commands.userToBech32(this.pubkey, []);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
public async repost() {
const query = await commands.repost(JSON.stringify(this.#raw));
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
}

View File

@@ -1,4 +1,4 @@
import type { Event } from "@lume/types";
import type { Event, NostrEvent } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
@@ -12,7 +12,7 @@ export function useEvent(id: string) {
.split("'")[0]
.split(".")[0];
const cmd: string = await invoke("get_event", { id: eventId });
const event: Event = JSON.parse(cmd);
const event: NostrEvent = JSON.parse(cmd);
return event;
} catch (e) {
throw new Error(e);

View File

@@ -0,0 +1,53 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { commands } from "../commands";
import { dedupEvents } from "../dedup";
import { NostrEvent } from "@lume/types";
export function useInfiniteEvents(
contacts: string[],
label: string,
account: string,
nsfw?: boolean,
) {
const pubkeys = contacts;
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
try {
const until: string = pageParam > 0 ? pageParam.toString() : undefined;
const query = await commands.getLocalEvents(pubkeys, until);
if (query.status === "ok") {
const nostrEvents = query.data as unknown as NostrEvent[];
const events = dedupEvents(nostrEvents, nsfw);
return events;
} else {
throw new Error(query.error);
}
} catch (e) {
throw new Error(e);
}
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
return {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
};
}

View File

@@ -1,6 +1,6 @@
import type { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { commands } from "../commands";
export function useProfile(pubkey: string, embed?: string) {
const {
@@ -11,15 +11,16 @@ export function useProfile(pubkey: string, embed?: string) {
queryKey: ["user", pubkey],
queryFn: async () => {
try {
if (embed) {
const profile: Metadata = JSON.parse(embed);
return profile;
if (embed) return JSON.parse(embed) as Metadata;
const normalize = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const query = await commands.getProfile(normalize);
if (query.status === "ok") {
return JSON.parse(query.data) as Metadata;
} else {
throw new Error(query.error);
}
const id = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const cmd: Metadata = await invoke("get_profile", { id });
return cmd;
} catch (e) {
throw new Error(e);
}

View File

@@ -0,0 +1,8 @@
export * from "./event";
export * from "./account";
export * from "./query";
export * from "./window";
export * from "./commands";
export * from "./hooks/useEvent";
export * from "./hooks/useInfiniteEvents";
export * from "./hooks/useProfile";

View File

@@ -0,0 +1,306 @@
import { LumeColumn, Metadata, NostrEvent, Settings } from "@lume/types";
import { commands } from "./commands";
import { resolveResource } from "@tauri-apps/api/path";
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
import { open } from "@tauri-apps/plugin-dialog";
import { dedupEvents } from "./dedup";
import { invoke } from "@tauri-apps/api/core";
enum NSTORE_KEYS {
settings = "lume_user_settings",
columns = "lume_user_columns",
}
export class NostrQuery {
static async upload(filePath?: string) {
const allowExts = [
"png",
"jpeg",
"jpg",
"gif",
"mp4",
"mp3",
"webm",
"mkv",
"avi",
"mov",
];
const selected =
filePath ||
(
await open({
multiple: false,
filters: [
{
name: "Media",
extensions: allowExts,
},
],
})
).path;
// User cancelled action
if (!selected) return null;
try {
const file = await readFile(selected);
const blob = new Blob([file]);
const data = new FormData();
data.append("fileToUpload", blob);
data.append("submit", "Upload Image");
const res = await fetch("https://nostr.build/api/v2/upload/files", {
method: "POST",
body: data,
});
if (!res.ok) return null;
const json = await res.json();
const content = json.data[0];
return content.url as string;
} catch (e) {
throw new Error(String(e));
}
}
static async getProfile(pubkey: string) {
const normalize = pubkey.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const query = await commands.getProfile(normalize);
if (query.status === "ok") {
const profile: Metadata = JSON.parse(query.data);
return profile;
} else {
return null;
}
}
static async getEvent(id: string) {
const normalize: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const query = await commands.getEvent(normalize);
if (query.status === "ok") {
const event: NostrEvent = JSON.parse(query.data);
return event;
} else {
return null;
}
}
static async getUserEvents(pubkey: string, asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getEventsBy(pubkey, until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
return events;
} else {
return [];
}
}
static async getUserActivities(
account: string,
kind: "1" | "6" | "9735" = "1",
) {
const query = await commands.getActivities(account, kind);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
return events;
} else {
return [];
}
}
static async getLocalEvents(pubkeys: string[], asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getLocalEvents(pubkeys, until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
return dedup;
} else {
return [];
}
}
static async getGlobalEvents(asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getGlobalEvents(until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
return dedup;
} else {
return [];
}
}
static async getHashtagEvents(hashtags: string[], asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const nostrTags = hashtags.map((tag) => tag.replace("#", ""));
const query = await commands.getHashtagEvents(nostrTags, until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
return dedup;
} else {
return [];
}
}
static async verifyNip05(pubkey: string, nip05?: string) {
if (!nip05) return false;
const query = await commands.verifyNip05(pubkey, nip05);
if (query.status === "ok") {
return query.data;
} else {
return false;
}
}
static async getNstore(key: string) {
const query = await commands.getNstore(key);
if (query.status === "ok") {
const data: string | string[] = query.data
? JSON.parse(query.data)
: null;
return data;
} else {
return null;
}
}
static async setNstore(key: string, value: string) {
const query = await commands.setNstore(key, value);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async getSettings() {
const query = await commands.getNstore(NSTORE_KEYS.settings);
if (query.status === "ok") {
const settings: Settings = query.data ? JSON.parse(query.data) : null;
const isGranted = await isPermissionGranted();
const theme: "auto" | "light" | "dark" = await invoke(
"plugin:theme|get_theme",
);
return { ...settings, theme, notification: isGranted };
} else {
const initial: Settings = {
autoUpdate: false,
enhancedPrivacy: false,
notification: false,
zap: false,
nsfw: false,
gossip: false,
theme: "auto",
};
return initial;
}
}
static async setSettings(settings: Settings) {
const query = await commands.setNstore(
NSTORE_KEYS.settings,
JSON.stringify(settings),
);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async getColumns() {
const systemPath = "resources/system_columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
const query = await commands.getNstore(NSTORE_KEYS.columns);
if (query.status === "ok") {
const columns: LumeColumn[] = query.data ? JSON.parse(query.data) : [];
if (columns.length < 1) {
return systemColumns;
}
return columns;
} else {
return systemColumns;
}
}
static async setColumns(columns: LumeColumn[]) {
const content = JSON.stringify(columns);
const query = await commands.setNstore(NSTORE_KEYS.columns, content);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async getRelays() {
const query = await commands.getRelays();
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async connectRelay(url: string) {
const relayUrl = new URL(url);
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
const query = await commands.connectRelay(relayUrl.toString());
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
}
static async removeRelay(url: string) {
const relayUrl = new URL(url);
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
const query = await commands.removeRelay(relayUrl.toString());
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
}
}

View File

@@ -0,0 +1,140 @@
import { NostrEvent } from "@lume/types";
import { commands } from "./commands";
export class LumeWindow {
static async openEvent(event: NostrEvent) {
const eTags = event.tags.filter((tag) => tag[0] === "e" || tag[0] === "q");
const root: string =
eTags.find((el) => el[3] === "root")?.[1] ?? eTags[0]?.[1];
const reply: string =
eTags.find((el) => el[3] === "reply")?.[1] ?? eTags[1]?.[1];
const label = `event-${event.id}`;
const url = `/events/${root ?? reply ?? event.id}`;
const query = await commands.openWindow(label, "Thread", url, 500, 800);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async openProfile(pubkey: string) {
const label = `user-${pubkey}`;
const query = await commands.openWindow(
label,
"Profile",
`/users/${pubkey}`,
500,
800,
);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async openEditor(reply_to?: string, quote = false) {
let url: string;
if (reply_to) {
url = `/editor?reply_to=${reply_to}&quote=${quote}`;
} else {
url = "/editor";
}
const label = `editor-${reply_to ? reply_to : 0}`;
const query = await commands.openWindow(label, "Editor", url, 560, 340);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async openZap(id: string, pubkey: string) {
const nwc = await commands.loadNwc();
if (nwc.status === "ok") {
const status = nwc.data;
if (!status) {
const label = "nwc";
await commands.openWindow(
label,
"Nostr Wallet Connect",
"/nwc",
400,
600,
);
} else {
const label = `zap-${id}`;
await commands.openWindow(
label,
"Zap",
`/zap/${id}?pubkey=${pubkey}`,
400,
500,
);
}
} else {
throw new Error(nwc.error);
}
}
static async openSettings() {
const label = "settings";
const query = await commands.openWindow(
label,
"Settings",
"/settings",
800,
500,
);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async openSearch() {
const label = "search";
const query = await commands.openWindow(
label,
"Search",
"/search",
400,
600,
);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async openActivity(account: string) {
const label = "activity";
const query = await commands.openWindow(
label,
"Activity",
`/activity/${account}/texts`,
400,
600,
);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
}

View File

@@ -4,6 +4,8 @@ export interface Settings {
autoUpdate: boolean;
zap: boolean;
nsfw: boolean;
gossip: boolean;
theme: "auto" | "light" | "dark";
[key: string]: string | number | boolean;
}
@@ -26,7 +28,7 @@ export enum Kind {
// #TODO: Add all nostr kinds
}
export interface Event {
export interface NostrEvent {
id: string;
pubkey: string;
created_at: number;
@@ -34,11 +36,10 @@ export interface Event {
tags: string[][];
content: string;
sig: string;
relay?: string;
}
export interface EventWithReplies extends Event {
replies: Array<Event>;
export interface EventWithReplies extends NostrEvent {
replies: Array<NostrEvent>;
}
export interface Metadata {

View File

@@ -4,49 +4,17 @@
"private": true,
"main": "./src/index.ts",
"dependencies": {
"@getalby/sdk": "^3.5.1",
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.36.0",
"@tanstack/react-router": "^1.32.5",
"framer-motion": "^11.2.0",
"get-urls": "^12.1.0",
"media-chrome": "^3.2.2",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"qrcode.react": "^3.1.0",
"re-resizable": "^6.9.16",
"react": "^18.3.1",
"react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.51.4",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.1",
"react-snap-carousel": "^0.4.0",
"react-string-replace": "^1.1.1",
"slate": "^0.103.0",
"slate-react": "^0.102.0",
"sonner": "^1.4.41",
"string-strip-html": "^13.4.8",
"uqr": "^0.1.2",
"use-debounce": "^10.0.0",
"virtua": "^0.31.0"
"react-snap-carousel": "^0.4.0"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/react": "^18.3.2",
"@types/react": "^18.3.3",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5"
}

Some files were not shown because too many files have changed in this diff Show More