Compare commits

..

34 Commits

Author SHA1 Message Date
reya
6996e30889 chore: update github ci 2024-06-07 11:26:20 +07:00
reya
7ba793fad8 chore: bump version 2024-06-07 10:03:04 +07:00
reya
f11f836518 chore: update tray icon 2024-06-07 09:56:57 +07:00
reya
04fe0fcec8 feat: respect the relay hint 2024-06-07 09:07:33 +07:00
雨宮蓮
799835a629 Notification Panel (#200)
* feat: add tauri nspanel

* feat: add notification panel

* feat: move notification service to backend

* feat: add sync notification job

* feat: enable panel to join all spaces including fullscreen (#203)

* feat: fetch notification

* feat: listen for new notification

* feat: finish panel

---------

Co-authored-by: Victor Aremu <me@victorare.mu>
2024-06-06 14:32:30 +07:00
XIAO YU
4e7da4108b feat: Add get user following function (#202)
* feat: Add get user following function

* refactor: Refactor get_following function to use state and string public key

* feat: Update get_following function to use timeout duration

* feat: Fix connect_remote_account function to return remote_npub without conversion

* feat: Refactor get_following function to handle public key parsing errors

* Refactor get_followers function to handle public key parsing errors and use timeout duration
2024-06-05 13:24:32 +07:00
reya
7c7b082b3a fix: memory leak in image component 2024-06-03 07:32:34 +07:00
reya
38d6c51921 feat: use nostrdb for unix and rocksdb for windows 2024-06-02 08:16:59 +07:00
reya
1738cbdd97 chore: upgrade tauri 2024-06-01 14:54:35 +07:00
reya
2e885b76a1 feat: improve text wrap 2024-06-01 08:27:22 +07:00
reya
f94680e487 fix: column overlapped after change account 2024-05-31 15:25:47 +07:00
XIAO YU
c682a58842 chore: Remove unused modules and update metadata.rs (#199) 2024-05-31 12:53:35 +07:00
reya
921cf871ee chore: bump version 2024-05-31 08:54:53 +07:00
reya
d5b1593aca feat: improve nostr connect flow 2024-05-31 08:54:17 +07:00
reya
6676b4e2a4 Revert "chore: Update dependencies and add thiserror crate (#196)"
This reverts commit e254ee3203.
2024-05-30 15:40:44 +07:00
reya
5f30ddcfca Merge branch 'main' of github.com:lumehq/lume 2024-05-30 15:23:11 +07:00
reya
41d0de539d feat: revamp nostr connect flow 2024-05-30 15:21:33 +07:00
XIAO YU
e254ee3203 chore: Update dependencies and add thiserror crate (#196) 2024-05-30 07:12:46 +07:00
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
reya
7449000f5f chore: bump version 2024-05-23 08:01:04 +07:00
reya
dc7762ca11 fix: column lose state when navigate back 2024-05-23 08:00:33 +07:00
XIAO YU
3a3f960dde refactor: use borrowed references for adding and connecting relays (#191) 2024-05-23 07:08:39 +07:00
reya
12e066ff2e fix: web 2024-05-22 15:36:41 +07:00
reya
fe4f965ed5 chore: update website 2024-05-22 15:35:41 +07:00
149 changed files with 15594 additions and 12860 deletions

View File

@@ -16,6 +16,8 @@ jobs:
args: "--target aarch64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target x86_64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target universal-apple-darwin"
#- platform: 'ubuntu-22.04'
# args: ''
#- platform: 'windows-latest'

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",
@@ -21,43 +21,43 @@
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@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.9",
"i18next": "^23.11.5",
"i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.5.2",
"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.9",
"@tanstack/router-vite-plugin": "^1.34.8",
"@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.

After

Width:  |  Height:  |  Size: 26 KiB

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

@@ -3,7 +3,7 @@ import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
export function Column({
column,
@@ -17,7 +17,10 @@ export function Column({
isResize: boolean;
}) {
const container = useRef<HTMLDivElement>(null);
const webviewLabel = `column-${account}_${column.label}`;
const webviewLabel = useMemo(
() => `column-${account}_${column.label}`,
[account],
);
const [isCreated, setIsCreated] = useState(false);
@@ -48,6 +51,8 @@ export function Column({
}, [isScroll]);
useEffect(() => {
if (!container?.current) return;
const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
@@ -59,11 +64,16 @@ export function Column({
width: rect.width,
height: rect.height,
url,
}).then(() => setIsCreated(true));
}).then(() => {
console.log("created: ", webviewLabel);
setIsCreated(true);
});
// close webview when unmounted
return () => {
invoke("close_column", { label: webviewLabel });
invoke("close_column", { label: webviewLabel }).then(() => {
console.log("closed: ", webviewLabel);
});
};
}, [account]);

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

@@ -102,7 +102,7 @@ export function NoteContent({
<div className="flex flex-col gap-2">
<div
className={cn(
"select-text text-[15px] text-balance break-words overflow-hidden",
"select-text text-[15px] text-pretty content-break overflow-hidden",
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
className,
)}

View File

@@ -145,7 +145,7 @@ export function NoteContentLarge({
return (
<div className={cn("select-text", className)}>
<div className="text-[15px] text-balance content-break leading-normal">
<div className="text-[15px] text-pretty content-break leading-normal">
{content}
</div>
</div>

View File

@@ -2,7 +2,7 @@ export function Hashtag({ tag }: { tag: string }) {
return (
<button
type="button"
className="break-all cursor-default leading-normal group"
className="break-all cursor-default leading-normal group text-start"
>
<span className="text-blue-500">#</span>
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">

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);
@@ -50,7 +48,7 @@ export function MentionNote({
</User.Provider>
<div
className={cn(
"px-3 select-text content-break whitespace-normal text-balance leading-normal",
"px-3 select-text whitespace-normal text-pretty content-break leading-normal",
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
)}
>
@@ -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,61 +1,44 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { download } from "@tauri-apps/plugin-upload";
import { type SyntheticEvent, useState } from "react";
export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false);
const open = async (url: string) => {
const name = new URL(url).pathname
.split("/")
.pop()
.replace(/[^a-zA-Z ]/g, "");
const label = `viewer-${name}`;
const window = WebviewWindow.getByLabel(label);
const downloadImage = async (e: { stopPropagation: () => void }) => {
try {
e.stopPropagation();
if (!window) {
const newWindow = new WebviewWindow(label, {
url,
title: "Image Viewer",
width: 800,
height: 800,
titleBarStyle: "overlay",
});
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1);
await download(url, `${downloadDirPath}/${filename}`);
setDownloaded(true);
} catch (e) {
console.error(e);
return newWindow;
}
};
const open = async () => {
const name = new URL(url).pathname.split("/").pop();
return new WebviewWindow("image-viewer", {
url,
title: name,
});
};
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
event.currentTarget.src = "/fallback-image.jpg";
return await window.setFocus();
};
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div onClick={() => open()} className="group relative my-1">
<div className="group relative my-1">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
onError={fallback}
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
>
{downloaded ? (
<CheckCircleIcon className="size-4" />
) : (
<DownloadIcon className="size-4" />
)}
</button>
</div>
);
}

View File

@@ -36,6 +36,10 @@ export function Images({ urls }: { urls: string[] }) {
style={{ contentVisibility: "auto" }}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(urls[0])}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
);
@@ -54,6 +58,10 @@ export function Images({ urls }: { urls: string[] }) {
style={{ contentVisibility: "auto" }}
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(item)}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</CarouselItem>
)}

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

@@ -23,7 +23,7 @@ export function UserAvatar({ className }: { className?: string }) {
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("outline-[.5px] outline-black/5", className)}
className={cn("outline-[.5px] outline-black/5 object-cover", className)}
/>
<Avatar.Fallback delayMs={120}>
<img

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,50 +1,41 @@
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 = await NostrQuery.getColumns();
return columns;
},
gcTime: 0,
shouldReload: false,
component: Screen,
});
function Screen() {
const userSavedColumns = Route.useLoaderData();
const { account } = Route.useParams();
const initialColumnList = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams();
const { ark } = Route.useRouteContext();
const [columns, setColumns] = useState<LumeColumn[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [columns, setColumns] = useState([]);
const [isScroll, setIsScroll] = useState(false);
const [isResize, setIsResize] = useState(false);
const reset = () => {
setColumns(null);
setSelectedIndex(-1);
};
const goLeft = () => {
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
@@ -80,7 +71,7 @@ function Screen() {
// scroll to the newest column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end",
align: "center",
});
}, 150);
@@ -115,16 +106,19 @@ function Screen() {
);
useEffect(() => {
setColumns(userSavedColumns);
}, [userSavedColumns]);
setColumns(initialColumnList);
}, [initialColumnList]);
useEffect(() => {
// save state
ark.set_columns(columns);
// save current state
if (columns?.length) {
NostrQuery.setColumns(columns).then(() => console.log("saved"));
}
}, [columns]);
useEffect(() => {
const unlistenColEvent = listen<EventColumns>("columns", (data) => {
if (data.payload.type === "reset") reset();
if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title")
@@ -148,15 +142,14 @@ 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) => (
{columns?.map((column) => (
<Column
key={column.label}
key={account + column.label}
column={column}
account={account}
isScroll={isScroll}

View File

@@ -1,38 +1,29 @@
import {
BellIcon,
ComposeFilledIcon,
HorizontalDotsIcon,
PlusIcon,
SearchIcon,
} from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { User } from "@/components/user";
import {
cn,
decodeZapInvoice,
displayNpub,
sendNativeNotification,
} from "@lume/utils";
import { cn } from "@lume/utils";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
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 } from "@lume/system";
import { Link } from "@tanstack/react-router";
export const Route = createFileRoute("/$account")({
beforeLoad: async ({ context }) => {
const ark = context.ark;
const accounts = await ark.get_accounts();
beforeLoad: async () => {
const accounts = await NostrAccount.getAccounts();
return { accounts };
},
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,27 +36,25 @@ 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" />
New Post
</button>
<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 +70,7 @@ function Screen() {
}
function Accounts() {
const { ark, accounts } = Route.useRouteContext();
const { accounts } = Route.useRouteContext();
const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null);
@@ -102,14 +91,23 @@ 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);
// Change current account and update signer
const select = await NostrAccount.loadAccount(npub);
if (select) {
return navigate({ to: "/$account/home", params: { account: npub } });
// Reset current columns
await getCurrent().emit("columns", { type: "reset" });
// Redirect to new account
return navigate({
to: "/$account/home",
params: { account: npub },
resetScroll: true,
replace: true,
});
} else {
toast.warning("Something wrong.");
}
@@ -188,66 +186,3 @@ function Accounts() {
</div>
);
}
function Bell() {
const { ark } = Route.useRouteContext();
const { account } = Route.useParams();
const [count, setCount] = useState(0);
useEffect(() => {
const unlisten = getCurrent().listen<string>(
"activity",
async (payload) => {
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 userName =
user.display_name || user.name || displayNpub(event.pubkey, 16);
switch (event.kind) {
case Kind.Text: {
sendNativeNotification("Mentioned you in a note", userName);
break;
}
case Kind.Repost: {
sendNativeNotification("Reposted your note", userName);
break;
}
case Kind.ZapReceipt: {
const amount = decodeZapInvoice(event.tags);
sendNativeNotification(
`Zapped ₿ ${amount.bitcoinFormatted}`,
userName,
);
break;
}
default:
break;
}
},
);
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<button
type="button"
onClick={() => {
setCount(0);
ark.open_activity(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"
>
<BellIcon className="size-5" />
{count > 0 ? (
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
) : null}
</button>
);
}

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

@@ -1,61 +0,0 @@
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/texts")({
loader: async ({ context, params }) => {
const ark = context.ark;
return { data: defer(ark.get_activities(params.account, "1")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<Note.Provider event={event}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.Activity className="px-3" />
<Note.Content className="px-3" quote={false} clean />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}

View File

@@ -1,50 +0,0 @@
import { Box, Container } from "@lume/ui";
import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/activity/$account")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
return (
<Container withDrag withNavigate={false}>
<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">
<Link to="/activity/$account/texts" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Notes
</div>
)}
</Link>
<Link to="/activity/$account/zaps" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
>
Zaps
</div>
)}
</Link>
</div>
</div>
<div className="px-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
<Outlet />
</div>
</Box>
</Container>
);
}

View File

@@ -1,65 +0,0 @@
import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
import { decodeZapInvoice } from "@lume/utils";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
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")) };
},
component: Screen,
});
function Screen() {
const { data } = Route.useLoaderData();
return (
<Virtualizer overscan={3}>
<Suspense
fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium"
disabled
>
<Spinner className="size-5" />
Loading...
</button>
</div>
}
>
<Await promise={data}>
{(events) =>
events.map((event) => (
<div
key={event.id}
className="flex flex-col gap-2 mb-3 bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex flex-col">
<div className="text-lg h-20 font-medium leading-tight flex w-full items-center justify-center">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
<div className="h-11 border-t border-neutral-100 dark:border-neutral-900 flex items-center gap-1 px-2">
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-7 rounded-full shrink-0" />
<User.Name className="text-sm font-medium" />
</div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
zapped you
</div>
</div>
</User.Root>
</User.Provider>
</div>
))
}
</Await>
</Suspense>
</Virtualizer>
);
}

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);
@@ -39,8 +33,8 @@ function Screen() {
}
return navigate({
to: "/auth/settings",
search: { account },
to: "/auth/$account/settings",
params: { account },
});
}

View File

@@ -1,59 +1,30 @@
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 { 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 },
};
export const Route = createFileRoute("/auth/$account/settings")({
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 { account } = Route.useParams();
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 toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const navigate = Route.useNavigate();
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
@@ -82,7 +53,7 @@ function Screen() {
setLoading(true);
// publish settings
const eventId = await ark.set_settings(newSettings);
const eventId = await NostrQuery.setSettings(newSettings);
if (eventId) {
return navigate({
@@ -114,22 +85,6 @@ function Screen() {
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Push Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Enabling push notifications will allow you to receive
notifications from Lume.
</p>
</div>
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<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 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Enhanced Privacy</h3>
@@ -146,21 +101,6 @@ function Screen() {
<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 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
>
<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 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-white/10">
<div className="flex-1">
<h3 className="font-semibold">Zap</h3>
@@ -200,7 +140,7 @@ function Screen() {
disabled={loading}
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
{loading ? <Spinner /> : t("global.continue")}
</button>
</div>
</div>

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,12 @@ 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 },
to: "/auth/$account/settings",
params: { 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,12 @@ function Screen() {
try {
setLoading(true);
const npub = await ark.nostr_connect(uri);
const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
if (npub) {
navigate({
to: "/auth/settings",
search: { account: npub },
if (remoteAccount?.length) {
return navigate({
to: "/auth/$account/settings",
params: { account: remoteAccount },
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,21 +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 contacts = Route.useLoaderData();
const navigate = Route.useNavigate();
const { ark } = Route.useRouteContext();
const { label, redirect } = Route.useSearch();
const [title, setTitle] = useState("");
const [npub, setNpub] = useState("");
const [users, setUsers] = useState<string[]>([
@@ -36,6 +30,10 @@ function Screen() {
]);
const [isLoading, setIsLoading] = useState(false);
const contacts = Route.useLoaderData();
const search = Route.useSearch();
const navigate = Route.useNavigate();
const toggleUser = (pubkey: string) => {
setUsers((prev) =>
prev.includes(pubkey)
@@ -56,11 +54,14 @@ function Screen() {
try {
setIsLoading(true);
const key = `lume_group_${label}`;
const createGroup = await ark.set_nstore(key, JSON.stringify(users));
const key = `lume_group_${search.label}`;
const createGroup = await NostrQuery.setNstore(
key,
JSON.stringify(users),
);
if (createGroup) {
return navigate({ to: redirect });
return navigate({ to: search.redirect, search: { ...search } });
}
} catch (e) {
setIsLoading(false);

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,12 +19,10 @@ export const Route = createFileRoute("/create-topic")({
});
function Screen() {
const { label, redirect } = Route.useSearch();
const { ark } = Route.useRouteContext();
const [topics, setTopics] = useState<Topic[]>([]);
const [isLoading, setIsLoading] = useState(false);
const search = Route.useSearch();
const navigate = Route.useNavigate();
const toggleTopic = (topic: Topic) => {
@@ -38,11 +37,14 @@ function Screen() {
try {
setIsLoading(true);
const key = `lume_topic_${label}`;
const createTopic = await ark.set_nstore(key, JSON.stringify(topics));
const key = `lume_topic_${search.label}`;
const createTopic = await NostrQuery.setNstore(
key,
JSON.stringify(topics),
);
if (createTopic) {
return navigate({ to: redirect });
return navigate({ to: search.redirect, search: { ...search } });
}
} catch (e) {
setIsLoading(false);

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,8 +35,9 @@ 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 },
@@ -62,62 +57,50 @@ function Screen() {
});
return (
<div className="relative flex h-full w-full items-center justify-center">
<div
data-tauri-drag-region
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

@@ -0,0 +1,314 @@
import { Note } from "@/components/note";
import { User } from "@/components/user";
import { LumeWindow, NostrQuery, useEvent } from "@lume/system";
import { Kind, NostrEvent } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
import { useEffect, useMemo, useState } from "react";
import * as Tabs from "@radix-ui/react-tabs";
import { InfoIcon, RepostIcon, SettingsIcon } from "@lume/icons";
import { decodeZapInvoice, formatCreatedAt } from "@lume/utils";
interface EmitAccount {
account: string;
}
export const Route = createFileRoute("/panel")({
component: Screen,
});
function Screen() {
const [account, setAccount] = useState<string>(null);
const [events, setEvents] = useState<NostrEvent[]>([]);
const texts = useMemo(
() => events.filter((ev) => ev.kind === Kind.Text),
[events],
);
const zaps = useMemo(() => {
const groups = new Map<string, NostrEvent[]>();
const list = events.filter((ev) => ev.kind === Kind.ZapReceipt);
for (const event of list) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (groups.has(rootId)) {
groups.get(rootId).push(event);
} else {
groups.set(rootId, [event]);
}
}
}
return groups;
}, [events]);
const reactions = useMemo(() => {
const groups = new Map<string, NostrEvent[]>();
const list = events.filter(
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
);
for (const event of list) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (groups.has(rootId)) {
groups.get(rootId).push(event);
} else {
groups.set(rootId, [event]);
}
}
}
return groups;
}, [events]);
useEffect(() => {
if (account?.length && account?.startsWith("npub1")) {
NostrQuery.getNotifications()
.then((data) => {
const sorted = data.sort((a, b) => b.created_at - a.created_at);
setEvents(sorted);
})
.catch((e) => console.log(e));
}
}, [account]);
useEffect(() => {
const unlistenLoad = getCurrent().listen<EmitAccount>(
"load-notification",
(data) => {
setAccount(data.payload.account);
},
);
const unlistenNewEvent = getCurrent().listen("notification", (data) => {
const event: NostrEvent = JSON.parse(data.payload as string);
setEvents((prev) => [event, ...prev]);
});
return () => {
unlistenLoad.then((f) => f());
unlistenNewEvent.then((f) => f());
};
}, []);
if (!account) {
return (
<div className="w-full h-full flex items-center justify-center text-sm">
Please log in.
</div>
);
}
return (
<div className="w-full h-full flex flex-col">
<div className="h-11 shrink-0 flex items-center justify-between border-b border-black/5 px-4">
<div>
<h1 className="text-sm font-semibold">Notifications</h1>
</div>
<div className="inline-flex items-center gap-2">
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-7 rounded-full" />
</User.Root>
</User.Provider>
<button
type="button"
onClick={() => LumeWindow.openSettings()}
className="size-7 inline-flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-full"
>
<SettingsIcon className="size-4" />
</button>
</div>
</div>
<Tabs.Root
defaultValue="replies"
className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-none"
>
<Tabs.List className="flex items-center">
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
value="replies"
>
Replies
</Tabs.Trigger>
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
value="reactions"
>
Reactions
</Tabs.Trigger>
<Tabs.Trigger
className="flex-1 inline-flex h-8 items-center justify-center gap-2 px-2 text-sm font-medium border-b border-black/10 data-[state=active]:border-black/30 dark:data-[state=active] data-[state=inactive]:opacity-50"
value="zaps"
>
Zaps
</Tabs.Trigger>
</Tabs.List>
<div className="p-2">
<Tabs.Content value="replies" className="flex flex-col gap-2">
{texts.map((event) => (
<TextNote event={event} />
))}
</Tabs.Content>
<Tabs.Content value="reactions" className="flex flex-col gap-2">
{[...reactions.entries()].map(([root, events]) => (
<div
key={root}
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
>
<div className="min-w-0 flex-1 flex flex-col gap-2">
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider pubkey={event.pubkey}>
<User.Root className="shrink-0 flex rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
<User.Avatar className="flex-1 size-7 rounded-full" />
<div className="flex-1 size-7 rounded-full inline-flex items-center justify-center text-xs truncate">
{event.kind === Kind.Reaction ? (
event.content === "+" ? (
"👍"
) : (
event.content
)
) : (
<RepostIcon className="size-4 dark:text-teal-600 text-teal-400" />
)}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tabs.Content>
<Tabs.Content value="zaps" className="flex flex-col gap-2">
{[...zaps.entries()].map(([root, events]) => (
<div
key={root}
className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10"
>
<div className="min-w-0 flex-1 flex flex-col gap-2">
<div className="pb-2 border-b border-black/5 dark:border-white/5 flex items-center gap-2">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider
pubkey={event.tags.find((tag) => tag[0] == "P")[1]}
>
<User.Root className="shrink-0 flex gap-1.5 rounded-full h-8 bg-black/10 dark:bg-white/10 backdrop-blur-md p-[2px]">
<User.Avatar className="flex-1 size-7 rounded-full" />
<div className="flex-1 h-7 w-max pr-1.5 rounded-full inline-flex items-center justify-center text-sm truncate">
{decodeZapInvoice(event.tags).bitcoinFormatted}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tabs.Content>
</div>
</Tabs.Root>
</div>
);
}
function RootNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
return (
<div className="pb-2 mb-2 flex items-center">
<div className="size-8 shrink-0 rounded-full bg-black/20 dark:bg-white/20 animate-pulse" />
<div className="animate-pulse rounded-md h-4 w-2/3 bg-black/20 dark:bg-white/20" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center">
<InfoIcon className="size-5" />
</div>
<p className="text-red-500 text-sm">
Event not found with your current relay set
</p>
</div>
);
}
return (
<Note.Provider event={data}>
<Note.Root className="flex items-center gap-2">
<User.Provider pubkey={data.pubkey}>
<User.Root className="shrink-0">
<User.Avatar className="size-8 shrink-0 rounded-full" />
</User.Root>
</User.Provider>
<div className="line-clamp-1">{data.content}</div>
</Note.Root>
</Note.Provider>
);
}
function TextNote({ event }: { event: NostrEvent }) {
const pTags = event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1])
.slice(0, 3);
return (
<button
type="button"
key={event.id}
onClick={() => LumeWindow.openEvent(event)}
>
<Note.Provider event={event}>
<Note.Root className="shrink-0 flex flex-col gap-1 rounded-lg p-2 backdrop-blur-md bg-black/10 dark:bg-white/10">
<User.Provider pubkey={event.pubkey}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="size-9 shrink-0 rounded-full" />
<div className="flex-1 flex flex-col">
<div className="w-full flex items-baseline justify-between">
<User.Name className="leading-tight text-sm font-semibold" />
<span className="leading-tight text-sm text-black/50 dark:text-white/50">
{formatCreatedAt(event.created_at)}
</span>
</div>
<div className="inline-flex items-baseline gap-1 text-xs">
<span className="leading-tight text-black/50 dark:text-white/50">
Reply to:
</span>
<div className="inline-flex items-baseline gap-1">
{pTags.map((replyTo) => (
<User.Provider pubkey={replyTo}>
<User.Root>
<User.Name className="leading-tight font-medium" />
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
</User.Root>
</User.Provider>
<div className="flex gap-2">
<div className="w-9 shrink-0" />
<div className="line-clamp-1">{event.content}</div>
</div>
</Note.Root>
</Note.Provider>
</button>
);
}

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

@@ -12,8 +12,8 @@
"dependencies": {
"@astrojs/check": "^0.5.10",
"@astrojs/tailwind": "^5.1.0",
"@fontsource/geist-mono": "^5.0.3",
"astro": "^4.8.3",
"@fontsource/alice": "^5.0.13",
"astro": "^4.9.2",
"astro-seo-meta": "^4.1.1",
"astro-seo-schema": "^4.0.2",
"schema-dts": "^1.1.2",

BIN
apps/web/public/bg.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,37 +0,0 @@
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_564_71)">
<rect width="824" height="824" rx="184" fill="white" style="fill:white;fill-opacity:1;"/>
<circle cx="267" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
<circle cx="267" cy="594" r="42" fill="url(#paint0_radial_564_71)" fill-opacity="0.5" style=""/>
<circle cx="267" cy="594" r="42" fill="url(#paint1_radial_564_71)" fill-opacity="0.3" style=""/>
<circle cx="557" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
<circle cx="557" cy="594" r="42" fill="url(#paint2_radial_564_71)" fill-opacity="0.5" style=""/>
<circle cx="557" cy="594" r="42" fill="url(#paint3_radial_564_71)" fill-opacity="0.3" style=""/>
<path d="M412 691C382.859 691 353.717 686.063 337.654 682.804C333.024 681.865 329.866 686.676 333.074 690.144C345.098 703.138 370.814 724 412 724C453.186 724 478.902 703.138 490.926 690.144C494.134 686.676 490.976 681.865 486.346 682.804C470.283 686.063 441.141 691 412 691Z" fill="url(#paint4_linear_564_71)" style=""/>
</g>
<defs>
<radialGradient id="paint0_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(241.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<radialGradient id="paint1_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(288.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<radialGradient id="paint2_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(531.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<radialGradient id="paint3_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(578.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<linearGradient id="paint4_linear_564_71" x1="293.565" y1="686.595" x2="316.497" y2="774.784" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
<stop offset="1" stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
</linearGradient>
<clipPath id="clip0_564_71">
<rect width="824" height="824" fill="white" style="fill:white;fill-opacity:1;"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

BIN
apps/web/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 318 KiB

View File

@@ -3,153 +3,96 @@ import { Seo } from "astro-seo-meta";
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Lume</title>
<Seo
title="Lume"
description="A multiple columns Nostr client for desktop."
keywords={[
"nostr",
"nostr client",
"social network",
"desktop app",
"timeline",
"application",
"columns",
]}
themeColor="#fafafa"
colorScheme="light"
facebook={{
image: "/og-image.jpg",
url: "https://lume.nu",
type: "website",
}}
twitter={{
image: "/og-image.jpg",
card: "summary",
}}
/>
</head>
<body
class="w-full h-full antialiased font-mono bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
>
<div class="max-w-2xl mx-auto w-full py-16 md:px-0 px-2">
<div class="flex flex-col gap-16">
<div class="prose dark:prose-invert prose-neutral max-w-none">
<h3>About Lume</h3>
<p>
Lume is a <b>Nostr client</b> for desktop include Linux, Windows and
macOS. It is free and open source, you can look at source code <a
href="https://github.com/lumehq/lume"
target="_blank">on Github</a
>. Lume is actively improving the app and adding new features, you
can expect new update every month.
</p>
<a href="#download">Download</a>
<h3>What is nostr & how does it work?</h3>
<p>
Nostr stands for Notes and Other Stuff Transmitted by Relays. It is
an open, permission-less protocol that aims to provide
censorship-resistance and interoperability. It can be used to create
social networks or just about any other type of app (other stuff
part of the acronym). It is not a single website or app, but the
glue that holds together many apps (clients) and <b>Lume</b> is one of
it.
</p>
<p>
At its core, nostr consists of relays and events. A person does
something (event) and this event is sent to a relay. The relay
stores the event, then waits for another person to request it. The
most common types of events are notes and reactions - the stuff
social media is made of, but there are many other types of events.
It works very similar to how any other app would work with a
database, except in nostr there is no single database, rather a
large number of relays that store the events.
</p>
<h3>Lume is multiple columns experience</h3>
<p>
Lume is display your timeline as multiple column, each column is
each different content and you can define your experience
</p>
<p>
You can create a column to display newsfeed from specific people,
you can create a column to display all contents related to some
hashtags. It all up to you.
</p>
<img
src="https://image.nostr.build/fd3e3cdeb4fb9f0f3de5c5e668a11dcae55f50cc9a78fc2b57b063240191a0f9.png"
alt="columns"
loading="lazy"
decoding="async"
class="w-full h-auto rounded-lg"
/>
<h3>"For You"</h3>
<p>
Unlike some social networks, they feed you by algorithm. In Lume,
you totally control what to will see
</p>
<img
src="https://image.nostr.build/5afd79de15929a4ac6f6e933791c942555baa4206fecee54fed61dde9fe167e1.png"
alt="for you"
loading="lazy"
decoding="async"
class="w-full h-auto rounded-lg"
/>
<h3 id="download">Download and Explore</h3>
<p>
(Universal) macOS: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/Lume_3.0.0_universal.dmg"
>Lume_3.0.0_universal.dmg
</a>
</p>
<p>
(x86-64) Windows 11: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/Lume_3.0.0_x64-setup.exe"
>Lume_3.0.0_x64-setup.exe
</a>
</p>
<p>
(x86-64) Ubuntu: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.deb"
>lume_3.0.0_amd64.deb
</a>
</p>
<p>
(x86-64) Fedora: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume-3.0.0-1.x86_64.rpm"
>lume-3.0.0-1.x86_64.rpm
</a>
</p>
<p>
(x86-64) Linux Flatpak: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.flatpak"
>lume_3.0.0_amd64.flatpak
</a>
</p>
<p>
(x86-64) Linux AppImage: <a
href="https://github.com/lumehq/lume/releases/download/v3.0.0/lume_3.0.0_amd64.AppImage"
>lume_3.0.0_amd64.AppImage
</a>
</p>
<p>
Support for ARM, RISC-V and Loongarch architecture are coming soon.
</p>
</div>
<div class="text-center">
<p class="text-sm text-neutral-500 dark:text-neutral-600">
Supported by <a
href="https://opensats.org"
target="_blank"
class="text-orange-500">Open Sats</a
> and Community
</p>
</div>
</div>
</div>
</body>
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Lume: The nostr client for desktop</title>
<Seo
title="Lume"
description="A friendly and scalable Nostr desktop client."
keywords={[
"nostr",
"nostr client",
"social network",
"desktop app",
"timeline",
"application",
"columns",
"tweetdeck",
]}
themeColor="#fafafa"
colorScheme="light"
facebook={{
image: "/og-image.jpg",
url: "https://lume.nu",
type: "website",
}}
twitter={{
image: "/og-image.jpg",
card: "summary",
}}
/>
</head>
<body
class="w-full h-full antialiased bg-neutral-50 dark:bg-neutral-950 text-neutral-950 dark:text-neutral-50"
>
<div class="py-10 flex flex-col gap-10">
<div class="mx-auto max-w-xl w-full flex flex-col gap-2">
<div class="mb-5">
<img
src="/icon.png"
alt="App Icon"
class="size-14 shadow-md shadow-neutral-500/50 rounded-xl object-cover transform-gpu -rotate-6 hover:animate-spin"
/>
</div>
<h1 class="text-xl font-serif font-semibold">
A friendly and scalable Nostr desktop client.
</h1>
<p class="text-sm font-medium text-neutral-700">
Lume is a <b>Nostr client</b> for desktop, including Linux, Windows, and
macOS. It is free and open-source; you can look at the source code on <a
href="https://github.com/lumehq/lume">GitHub</a
>. Lume is actively improving the app and adding new features; you can
expect a new update every month.
</p>
<p class="text-sm font-medium text-neutral-700">
<b>Latest version</b>: 4.0.4
</p>
<div
class="w-full h-[120px] sm:h-[80px] flex flex-col sm:flex-row sm:items-center sm:justify-start justify-center gap-2"
>
<a
href="https://github.com/lumehq/lume/releases/latest"
class="inline-flex items-center justify-center w-44 h-11 rounded-full bg-black hover:ring-2 ring-blue-500 ring-offset-2 text-white font-medium text-sm"
>Download for macOS</a
>
<span class="italic text-xs text-neutral-700"
>(Windows & Linux are coming later)</span
>
</div>
<div class="text-sm italic text-neutral-600">
* If you still need to use Lume on Windows and Linux, you can try v3 <a
href="https://github.com/lumehq/lume/releases/tag/v3.0.2"
class="text-blue-500">here</a
>
</div>
</div>
<div class="sm:max-w-3xl w-full mx-auto px-3 sm:px-0">
<video
class="aspect-video w-full h-auto rounded-xl"
autoplay
muted
controls
>
<source
src="https://video.nostr.build/4cc4df88caeb861b62e3f73bddbb5e0b5cf63617472a97d22f427e273ee0e127.mp4"
type="video/mp4"
/>
Your browser does not support the video tag.
</video>
</div>
</div>
</body>
</html>

View File

@@ -7,7 +7,7 @@ export default {
theme: {
extend: {
fontFamily: {
mono: ["Geist Mono", ...defaultTheme.fontFamily.mono],
serif: ["Alice", ...defaultTheme.fontFamily.serif],
},
},
},

View File

@@ -1,36 +1,35 @@
{
"name": "lume",
"private": true,
"version": "4.0.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"web:dev": "turbo run dev --filter web",
"desktop:dev": "turbo run dev --filter desktop2",
"desktop:build": "turbo run build --filter desktop2",
"tauri": "tauri"
},
"devDependencies": {
"@biomejs/biome": "1.7.3",
"@tauri-apps/cli": "2.0.0-beta.12",
"turbo": "^1.13.3"
},
"packageManager": "pnpm@8.9.0",
"engines": {
"node": ">=18"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0-beta.7",
"@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",
"@tauri-apps/plugin-fs": "2.0.0-beta.3",
"@tauri-apps/plugin-http": "2.0.0-beta.3",
"@tauri-apps/plugin-notification": "2.0.0-beta.3",
"@tauri-apps/plugin-os": "2.0.0-beta.3",
"@tauri-apps/plugin-process": "2.0.0-beta.3",
"@tauri-apps/plugin-shell": "2.0.0-beta.4",
"@tauri-apps/plugin-updater": "2.0.0-beta.3",
"@tauri-apps/plugin-upload": "2.0.0-beta.4"
}
"name": "lume",
"private": true,
"version": "4.0.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"web:dev": "turbo run dev --filter web",
"desktop:dev": "turbo run dev --filter desktop2",
"desktop:build": "turbo run build --filter desktop2",
"tauri": "tauri"
},
"devDependencies": {
"@biomejs/biome": "1.7.3",
"@tauri-apps/cli": "2.0.0-beta.20",
"turbo": "^1.13.3"
},
"packageManager": "pnpm@8.9.0",
"engines": {
"node": ">=18"
},
"dependencies": {
"@tauri-apps/api": "2.0.0-beta.13",
"@tauri-apps/plugin-clipboard-manager": "2.1.0-beta.3",
"@tauri-apps/plugin-dialog": "2.0.0-beta.5",
"@tauri-apps/plugin-fs": "2.0.0-beta.5",
"@tauri-apps/plugin-http": "2.0.0-beta.5",
"@tauri-apps/plugin-notification": "2.0.0-beta.5",
"@tauri-apps/plugin-os": "2.0.0-beta.5",
"@tauri-apps/plugin-process": "2.0.0-beta.5",
"@tauri-apps/plugin-shell": "2.0.0-beta.6",
"@tauri-apps/plugin-updater": "2.0.0-beta.5",
"@tauri-apps/plugin-upload": "2.0.0-beta.6"
}
}

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 };
}

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