Compare commits

...
51 Commits
Author SHA1 Message Date
reya 6e5d0f0e76 chore: bump version 2024-06-24 10:06:12 +07:00
XIAO YUandGitHub f0712e5961 refactor: improve error handling (#216) 2024-06-24 07:19:36 +07:00
雨宮蓮andGitHub 3fbd66dece Add bitcoin connect (#215)
* feat: add bitcoin connect

* feat: improve zap screen
2024-06-21 14:56:10 +07:00
reya 1283432632 feat: use native feature instead of react 2024-06-21 10:24:09 +07:00
reya 59eaaec903 feat: support content warning 2024-06-21 08:57:49 +07:00
雨宮蓮andGitHub 4f0f210076 Add lazy carousel (#214)
* refactor: carousel

* feat: improve image carousel
2024-06-21 07:51:11 +07:00
reya e4a317f038 chore: bump version 2024-06-20 13:23:05 +07:00
reya 9779d020c7 feat: improve list virtualization 2024-06-20 13:22:28 +07:00
雨宮蓮andGitHub f8280ec8ee fix: get replies function (#213) 2024-06-19 21:02:33 +07:00
XIAO YUandGitHub 6c26f8967b chore: Refactor code for better performance and reliability (#212) 2024-06-19 14:36:59 +07:00
reya e1424b851c fix: routes 2024-06-19 14:22:45 +07:00
reya d14e609579 chore: bump version 2024-06-19 14:14:34 +07:00
reya 8c0627ff27 fix: crash on startup 2024-06-19 14:14:03 +07:00
雨宮蓮andGitHub 18c133d096 Settings Manager (#211)
* refactor: landing screen

* fix: code debt

* feat: add settings screen

* chore: clean up

* feat: settings

* feat: small updates
2024-06-19 14:00:58 +07:00
reya 0061ecea78 feat: use native context menu in tray panel 2024-06-18 09:07:58 +07:00
reya d01cf8319d feat: native context menu 2024-06-17 15:31:59 +07:00
雨宮蓮andGitHub 843895d876 Make Lume Faster (#208)
* chore: fix some lint issues

* feat: refactor contact list

* feat: refactor relay hint

* feat: add missing commands

* feat: use new cache layer for react query

* feat: refactor column

* feat: improve relay hint

* fix: replace break with continue in parser

* refactor: publish function

* feat: add reply command

* feat: improve editor

* fix: quote

* chore: update deps

* refactor: note component

* feat: improve repost

* feat: improve cache

* fix: backup screen

* refactor: column manager
2024-06-17 13:52:06 +07:00
XIAO YUandGitHub 7c99ed39e4 chore: handle unwrap err (#210) 2024-06-16 08:11:48 +07:00
71be59b2e9 Move the event parser and dedup functions to Rust (#206)
* feat: improve js parser

* feat: move parser and dedup to rust

* fix: parser

* fix: get event function

* feat: improve parser performance (#207)

* feat: improve parser performance

* feat: add test for video parsing

* feat: finish new parser

---------

Co-authored-by: XIAO YU <xyzmhx@gmail.com>
2024-06-12 08:27:53 +07:00
reya 1c20512ecc chore: update deps 2024-06-10 13:15:57 +07:00
雨宮蓮andGitHub 90342c552f Customize Bootstrap Relays (#205)
* feat: add bootstrap relays file

* feat: add save bootstrap relays command

* feat: add customize bootstrap relays screen
2024-06-10 10:48:39 +07:00
reya b396c8a695 feat: upgrade to rust-nostr 0.32 2024-06-08 08:00:02 +07:00
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 YUandGitHub 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 YUandGitHub 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 YUandGitHub 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
雨宮蓮andGitHub 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 YUandGitHub b90ad1421f chore: update keys.rs and relay.rs (#193) 2024-05-26 07:28:16 +07:00
雨宮蓮andGitHub bba324ea53 refactor: use specta for commands (#192)
* feat: add tauri-specta

* refactor: system library

* chore: format
2024-05-25 15:21:40 +07:00
173 changed files with 9396 additions and 8688 deletions
+2
View File
@@ -16,6 +16,8 @@ jobs:
args: "--target aarch64-apple-darwin" args: "--target aarch64-apple-darwin"
- platform: "macos-latest" # for Intel based macs. - platform: "macos-latest" # for Intel based macs.
args: "--target x86_64-apple-darwin" args: "--target x86_64-apple-darwin"
- platform: "macos-latest" # for Intel based macs.
args: "--target universal-apple-darwin"
#- platform: 'ubuntu-22.04' #- platform: 'ubuntu-22.04'
# args: '' # args: ''
#- platform: 'windows-latest' #- platform: 'windows-latest'
+1 -1
View File
@@ -35,4 +35,4 @@ dist/
.DS_Store .DS_Store
*.pem *.pem
.vscode/ .vscode/
ndb/ .idea/
+20 -23
View File
@@ -9,55 +9,52 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lume/ark": "workspace:^", "@getalby/bitcoin-connect-react": "^3.5.3",
"@lume/icons": "workspace:^", "@lume/icons": "workspace:^",
"@lume/system": "workspace:^",
"@lume/ui": "workspace:^", "@lume/ui": "workspace:^",
"@lume/utils": "workspace:^", "@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-sync-storage-persister": "^5.36.0", "@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/react-query": "^5.36.0", "@tanstack/react-query": "^5.45.0",
"@tanstack/react-query-persist-client": "^5.36.0", "@tanstack/react-router": "^1.38.1",
"@tanstack/react-router": "1.32.5", "embla-carousel-react": "^8.1.5",
"i18next": "^23.11.4", "i18next": "^23.11.5",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"minidenticons": "^4.2.1", "minidenticons": "^4.2.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nostr-tools": "^2.5.2", "nostr-tools": "^2.7.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-currency-input-field": "^3.8.0", "react-currency-input-field": "^3.8.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.51.4", "react-hook-form": "^7.52.0",
"react-hotkeys-hook": "^4.5.0", "react-i18next": "^14.1.2",
"react-i18next": "^14.1.1",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"slate": "^0.103.0", "slate": "^0.103.0",
"slate-react": "^0.102.0", "slate-react": "^0.105.0",
"sonner": "^1.4.41", "use-debounce": "^10.0.1",
"use-debounce": "^10.0.0",
"virtua": "^0.31.0" "virtua": "^0.31.0"
}, },
"devDependencies": { "devDependencies": {
"@lume/tailwindcss": "workspace:^", "@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^", "@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^", "@lume/types": "workspace:^",
"@tanstack/router-devtools": "^1.32.5", "@tanstack/router-devtools": "^1.38.1",
"@tanstack/router-vite-plugin": "^1.32.2", "@tanstack/router-vite-plugin": "^1.38.0",
"@types/react": "^18.3.2", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.4",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.11", "vite": "^5.3.1",
"vite-plugin-top-level-await": "^1.4.1", "vite-plugin-top-level-await": "^1.4.1",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 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

-12
View File
@@ -2,18 +2,6 @@
@tailwind utilities; @tailwind utilities;
@tailwind components; @tailwind components;
*::-webkit-scrollbar {
@apply w-[5px];
}
*::-webkit-scrollbar-track {
@apply bg-transparent;
}
*::-webkit-scrollbar-thumb {
@apply rounded bg-black dark:bg-white;
}
@layer utilities { @layer utilities {
.content-break { .content-break {
word-break: break-word; word-break: break-word;
+6 -18
View File
@@ -1,41 +1,29 @@
import { Ark } from "@lume/ark"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 { RouterProvider, createRouter } from "@tanstack/react-router";
import { platform } from "@tauri-apps/plugin-os";
import React, { StrictMode } from "react"; import React, { StrictMode } from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import "./app.css"; import "./app.css";
import { type } from "@tauri-apps/plugin-os";
import i18n from "./locale"; import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file import { routeTree } from "./router.gen"; // auto generated file
const ark = new Ark();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const platformName = await platform(); const os = await type();
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
// Set up a Router instance // Set up a Router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: { context: {
ark,
queryClient, queryClient,
platform: platformName, platform: os,
}, },
Wrap: ({ children }) => { Wrap: ({ children }) => {
return ( return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}> <I18nextProvider i18n={i18n} defaultNS={"translation"}>
<PersistQueryClientProvider <QueryClientProvider client={queryClient}>
client={queryClient}
persistOptions={{ persister }}
>
{children} {children}
</PersistQueryClientProvider> </QueryClientProvider>
</I18nextProvider> </I18nextProvider>
); );
}, },
@@ -1,13 +1,13 @@
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { message } from "@tauri-apps/plugin-dialog";
import { import {
type Dispatch, type Dispatch,
type ReactNode, type ReactNode,
type SetStateAction, type SetStateAction,
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner";
export function AvatarUploader({ export function AvatarUploader({
setPicture, setPicture,
@@ -18,21 +18,17 @@ export function AvatarUploader({
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadAvatar = async () => { const uploadAvatar = async () => {
// start loading
setLoading(true);
try { try {
const image = await ark.upload(); setLoading(true);
const image = await NostrQuery.upload();
setPicture(image); setPicture(image);
} catch (e) { } catch (e) {
toast.error(String(e)); setLoading(false);
await message(String(e), { title: "Lume", kind: "error" });
} }
// stop loading
setLoading(false);
}; };
return ( return (
+2 -3
View File
@@ -1,16 +1,15 @@
import { User } from "@/components/user"; import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils"; import { getBitcoinDisplayValues } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) { export function Balance({ account }: { account: string }) {
const { ark } = useRouteContext({ strict: false });
const [balance, setBalance] = useState(0); const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]); const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => { useEffect(() => {
async function getBalance() { async function getBalance() {
const val = await ark.get_balance(); const val = await NostrAccount.getBalance();
setBalance(val); setBalance(val);
} }
+36 -24
View File
@@ -2,52 +2,61 @@ import { CancelIcon, CheckIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types"; import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/webviewWindow"; import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
type WindowEvent = {
scroll: boolean;
resize: boolean;
};
export function Column({ export function Column({
column, column,
account, account,
isScroll,
isResize,
}: { }: {
column: LumeColumn; column: LumeColumn;
account: string; account: string;
isScroll: boolean;
isResize: boolean;
}) { }) {
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const webviewLabel = `column-${account}_${column.label}`; const webviewLabel = `column-${account}_${column.label}`;
const [isCreated, setIsCreated] = useState(false); const [isCreated, setIsCreated] = useState(false);
const repositionWebview = async () => { const repositionWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect(); const newRect = container.current.getBoundingClientRect();
await invoke("reposition_column", { await invoke("reposition_column", {
label: webviewLabel, label: webviewLabel,
x: newRect.x, x: newRect.x,
y: newRect.y, y: newRect.y,
}); });
}; }, []);
const resizeWebview = async () => { const resizeWebview = useCallback(async () => {
const newRect = container.current.getBoundingClientRect(); const newRect = container.current.getBoundingClientRect();
await invoke("resize_column", { await invoke("resize_column", {
label: webviewLabel, label: webviewLabel,
width: newRect.width, width: newRect.width,
height: newRect.height, height: newRect.height,
}); });
}; }, []);
useEffect(() => { useEffect(() => {
if (isCreated) resizeWebview(); if (!isCreated) return;
}, [isResize]);
const unlisten = listen<WindowEvent>("child-webview", (data) => {
if (data.payload.scroll) repositionWebview();
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
});
return () => {
unlisten.then((f) => f());
};
}, [isCreated]);
useEffect(() => { useEffect(() => {
if (isScroll && isCreated) repositionWebview(); if (!container?.current) return;
}, [isScroll]);
useEffect(() => {
const rect = container.current.getBoundingClientRect(); const rect = container.current.getBoundingClientRect();
const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`; const url = `${column.content}?account=${account}&label=${column.label}&name=${column.name}`;
@@ -59,16 +68,21 @@ export function Column({
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
url, url,
}).then(() => setIsCreated(true)); }).then(() => {
console.log("created: ", webviewLabel);
setIsCreated(true);
});
// close webview when unmounted // close webview when unmounted
return () => { return () => {
invoke("close_column", { label: webviewLabel }); invoke("close_column", { label: webviewLabel }).then(() => {
console.log("closed: ", webviewLabel);
});
}; };
}, [account]); }, [account]);
return ( return (
<div className="h-full w-[440px] shrink-0 p-2"> <div className="h-full w-[500px] shrink-0 p-2">
<div <div
className={cn( className={cn(
"flex flex-col w-full h-full rounded-xl", "flex flex-col w-full h-full rounded-xl",
@@ -77,9 +91,7 @@ export function Column({
: "", : "",
)} )}
> >
{column.label !== "open" ? ( <Header label={column.label} name={column.name} />
<Header label={column.label} name={column.name} />
) : null}
<div ref={container} className="flex-1 w-full h-full" /> <div ref={container} className="flex-1 w-full h-full" />
</div> </div>
</div> </div>
@@ -112,10 +124,10 @@ function Header({ label, name }: { label: string; name: string }) {
}, [title]); }, [title]);
return ( return (
<div className="h-9 w-full flex items-center justify-between shrink-0 px-1"> <div className="flex items-center justify-between w-full px-1 h-9 shrink-0">
<div className="size-7" /> <div className="size-7" />
<div className="shrink-0 h-9 flex items-center justify-center"> <div className="flex items-center justify-center shrink-0 h-9">
<div className="relative flex gap-2 items-center"> <div className="relative flex items-center gap-2">
<div <div
contentEditable contentEditable
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
@@ -138,7 +150,7 @@ function Header({ label, name }: { label: string; name: string }) {
<button <button
type="button" type="button"
onClick={() => close()} onClick={() => close()}
className="size-7 inline-flex hover:bg-black/10 rounded-lg dark:hover:bg-white/10 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200" className="inline-flex items-center justify-center rounded-lg size-7 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
> >
<CancelIcon className="size-4" /> <CancelIcon className="size-4" />
</button> </button>
@@ -1,18 +1,17 @@
import { ThreadIcon } from "@lume/icons"; import { ThreadIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import type { LumeEvent } from "@lume/system";
import { useMemo } from "react";
export function Conversation({ export function Conversation({
event, event,
className, className,
}: { }: {
event: Event; event: LumeEvent;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false }); const thread = useMemo(() => event.thread, [event]);
const thread = ark.get_thread(event.tags);
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
@@ -23,7 +22,7 @@ export function Conversation({
)} )}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{thread?.root ? <Note.Child eventId={thread?.root} isRoot /> : null} {thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
<div className="flex items-center gap-2 px-3"> <div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400"> <div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<ThreadIcon className="size-4" /> <ThreadIcon className="size-4" />
@@ -31,15 +30,15 @@ export function Conversation({
</div> </div>
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" /> <div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div> </div>
{thread?.reply ? <Note.Child eventId={thread?.reply} /> : null} {thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
<div> <div>
<div className="px-3 h-14 flex items-center justify-between"> <div className="flex items-center justify-between px-3 h-14">
<Note.User /> <Note.User />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
</div> </div>
</div> </div>
<div className="flex items-center h-14 px-3"> <div className="flex items-center px-3 h-14">
<Note.Open /> <Note.Open />
</div> </div>
</Note.Root> </Note.Root>
@@ -1,26 +0,0 @@
import { cn } from "@lume/utils";
import { useNoteContext } from "./provider";
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]);
return (
<div className={cn("-mt-3 mb-2", className)}>
<div className="text-neutral-700 dark:text-neutral-300 inline-flex items-baseline gap-2 w-full overflow-hidden">
<div className="shrink-0 text-sm font-medium">To:</div>
{mentions.splice(0, 4).map((mention) => (
<User.Provider key={mention} pubkey={mention}>
<User.Root>
<User.Name className="text-sm font-medium" prefix="@" />
</User.Root>
</User.Provider>
))}
{mentions.length > 4 ? "..." : ""}
</div>
</div>
);
}
@@ -1,11 +1,10 @@
import { VisitIcon } from "@lume/icons"; import { VisitIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
import { LumeWindow } from "@lume/system";
export function NoteOpenThread() { export function NoteOpenThread() {
const event = useNoteContext(); const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
@@ -13,7 +12,7 @@ export function NoteOpenThread() {
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="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" 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" /> <VisitIcon className="shrink-0 size-4" />
@@ -1,12 +1,11 @@
import { ReplyIcon } from "@lume/icons"; import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { LumeWindow } from "@lume/system";
export function NoteReply({ large = false }: { large?: boolean }) { export function NoteReply({ large = false }: { large?: boolean }) {
const event = useNoteContext(); const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
@@ -14,7 +13,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => ark.open_editor(event.id)} onClick={() => LumeWindow.openEditor(event.id)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large large
@@ -1,101 +1,81 @@
import { QuoteIcon, RepostIcon } from "@lume/icons"; import { RepostIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { LumeWindow } from "@lume/system";
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 { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NoteRepost({ large = false }: { large?: boolean }) { export function NoteRepost({ large = false }: { large?: boolean }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
const { settings } = useRouteContext({ strict: false });
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false); const [isRepost, setIsRepost] = useState(false);
const repost = async () => { const repost = async () => {
if (isRepost) return;
try { try {
if (isRepost) return;
setLoading(true); setLoading(true);
// repost // repost
await ark.repost(event.id, event.pubkey); await event.repost();
// update state // update state
setLoading(false); setLoading(false);
setIsRepost(true); setIsRepost(true);
} catch {
// notify
toast.success("You've reposted this post successfully");
} catch (e) {
setLoading(false); setLoading(false);
toast.error("Repost failed, try again later"); await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
} }
}; };
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Quote",
action: async () => repost(),
}),
MenuItem.new({
text: "Repost",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
if (!settings.display_repost_button) return null;
return ( return (
<DropdownMenu.Root> <button
<Tooltip.Provider> type="button"
<Tooltip.Root delayDuration={150}> onClick={(e) => showContextMenu(e)}
<DropdownMenu.Trigger asChild> className={cn(
<Tooltip.Trigger asChild> "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
<button large
type="button" ? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
className={cn( : "size-7",
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full", )}
large >
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20" {loading ? (
: "size-7", <Spinner className="size-4" />
)} ) : (
> <RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
{loading ? ( )}
<Spinner className="size-4" /> {large ? "Repost" : null}
) : ( </button>
<RepostIcon
className={cn("size-4", isRepost ? "text-blue-500" : "")}
/>
)}
{large ? "Repost" : null}
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
{t("note.buttons.repost")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => repost()}
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"
>
<RepostIcon className="size-4" />
{t("note.buttons.repost")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_editor(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" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
); );
} }
@@ -1,33 +1,19 @@
import { ZapIcon } from "@lume/icons"; import { ZapIcon } from "@lume/icons";
import { useRouteContext, useSearch } from "@tanstack/react-router"; import { LumeWindow } from "@lume/system";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
export function NoteZap({ large = false }: { large?: boolean }) { export function NoteZap({ large = false }: { large?: boolean }) {
const event = useNoteContext(); const event = useNoteContext();
const { ark, settings } = useRouteContext({ strict: false }); const { settings } = useRouteContext({ strict: false });
const { account } = useSearch({ strict: false });
const zap = async () => { if (!settings.display_zap_button) return null;
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));
}
};
if (!settings.zap) return null;
return ( return (
<button <button
type="button" type="button"
onClick={() => zap()} onClick={() => LumeWindow.openZap(event.id, event.pubkey)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200", "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large large
+12 -11
View File
@@ -1,33 +1,34 @@
import { useEvent } from "@lume/ark"; import { useEvent } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Note } from "."; import { Note } from ".";
import { InfoIcon } from "@lume/icons"; import { InfoIcon } from "@lume/icons";
import type { EventTag } from "@lume/types";
export function NoteChild({ export function NoteChild({
eventId, event,
isRoot, isRoot,
}: { }: {
eventId: string; event: EventTag;
isRoot?: boolean; isRoot?: boolean;
}) { }) {
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(event.id, event.relayHint);
if (isLoading) { if (isLoading) {
return ( return (
<div className="pt-3 px-3 flex items-center gap-2"> <div className="flex items-center gap-2 px-3 pt-3">
<div className="size-8 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800 animate-pulse" /> <div className="rounded-full size-8 shrink-0 bg-neutral-200 dark:bg-neutral-800 animate-pulse" />
<div className="animate-pulse rounded-md h-4 w-2/3 bg-neutral-200 dark:bg-neutral-800" /> <div className="w-2/3 h-4 rounded-md animate-pulse bg-neutral-200 dark:bg-neutral-800" />
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return ( return (
<div className="pt-3 px-3 flex items-center gap-2"> <div className="flex items-center gap-2 px-3 pt-3">
<div className="size-8 shrink-0 rounded-full bg-red-500 text-white inline-flex items-center justify-center"> <div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
<InfoIcon className="size-5" /> <InfoIcon className="size-5" />
</div> </div>
<p className="text-red-500 text-sm"> <p className="text-sm text-red-500">
Event not found with your current relay set Event not found with your current relay set
</p> </p>
</div> </div>
@@ -37,7 +38,7 @@ export function NoteChild({
return ( return (
<Note.Provider event={data}> <Note.Provider event={data}>
<Note.Root className={cn(isRoot ? "mb-3" : "")}> <Note.Root className={cn(isRoot ? "mb-3" : "")}>
<div className="h-14 px-3 flex items-center justify-between"> <div className="flex items-center justify-between px-3 h-14">
<Note.User /> <Note.User />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
+80 -55
View File
@@ -1,5 +1,7 @@
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils"; import { cn } from "@lume/utils";
import { type ReactNode, useMemo } from "react"; import { useRouteContext } from "@tanstack/react-router";
import { nanoid } from "nanoid";
import { type ReactNode, useMemo, useState } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag"; import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note"; import { MentionNote } from "./mentions/note";
@@ -7,7 +9,6 @@ import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images"; import { Images } from "./preview/images";
import { Videos } from "./preview/videos"; import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { nanoid } from "nanoid";
export function NoteContent({ export function NoteContent({
quote = true, quote = true,
@@ -20,55 +21,48 @@ export function NoteContent({
clean?: boolean; clean?: boolean;
className?: string; className?: string;
}) { }) {
const { settings } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
const data = useMemo(() => {
const { content, images, videos } = parser(event.content);
const words = content.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
let richContent: ReactNode[] | string = content;
const warning = useMemo(() => event.warning, [event]);
const content = useMemo(() => {
try { try {
if (hashtags.length) { // Get parsed meta
for (const hashtag of hashtags) { const { content, hashtags, events, mentions } = event.meta;
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => { // Define rich content
return <Hashtag key={hashtag + index} tag={hashtag} />; let richContent: ReactNode[] | string = settings.display_media
}); ? content
: event.content;
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => {
return <Hashtag key={hashtag + index} tag={hashtag} />;
});
}
for (const event of events) {
if (quote) {
richContent = reactStringReplace(richContent, event, (_, index) => (
<MentionNote key={event + index} eventId={event} />
));
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
} }
} }
if (events.length) { for (const user of mentions) {
for (const event of events) { if (mention) {
if (quote) { richContent = reactStringReplace(richContent, user, (_, index) => (
richContent = reactStringReplace(richContent, event, (_, index) => ( <MentionUser key={user + index} pubkey={user} />
<MentionNote key={event + index} eventId={event} /> ));
));
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
}
} }
}
if (mentions.length) { if (!mention && clean) {
for (const user of mentions) { richContent = reactStringReplace(richContent, user, () => null);
if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => (
<MentionUser key={user + index} pubkey={user} />
));
}
if (!mention && clean) {
richContent = reactStringReplace(richContent, user, () => null);
}
} }
} }
@@ -81,7 +75,7 @@ export function NoteContent({
href={match} href={match}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="line-clamp-1 text-blue-500 hover:text-blue-600" className="inline text-blue-500 hover:text-blue-600"
> >
{match} {match}
</a> </a>
@@ -92,25 +86,56 @@ export function NoteContent({
<div key={nanoid()} className="h-3" /> <div key={nanoid()} className="h-3" />
)); ));
return { content: richContent, images, videos }; return richContent;
} catch (e) { } catch {
return { content, images, videos }; return event.content;
} }
}, []); }, [event.content]);
const [blurred, setBlurred] = useState(() => warning?.length > 0);
return ( return (
<div className="flex flex-col gap-2"> <div className="relative flex flex-col gap-2">
{blurred ? (
<div className="absolute inset-0 z-10 flex items-center justify-center w-full h-full bg-black/80 backdrop-blur-xl">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<p className="text-sm text-white/60">
The content is hidden because the author
<br />
marked it with a warning for a reason:
</p>
<p className="text-sm font-medium text-white">{warning}</p>
<button
type="button"
onClick={() => setBlurred(false)}
className="inline-flex items-center justify-center px-2 mt-4 text-sm font-medium border rounded-lg text-white/70 h-9 w-max bg-white/20 hover:bg-white/30 border-white/5"
>
View anyway
</button>
</div>
</div>
) : null}
<div <div
className={cn( className={cn(
"select-text text-[15px] text-balance break-words overflow-hidden", "select-text text-pretty content-break overflow-hidden",
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "", event.meta?.content.length > 400
? "max-h-[250px] gradient-mask-b-0"
: "",
className, className,
)} )}
> >
{data.content} {content}
</div> </div>
{data.images.length ? <Images urls={data.images} /> : null} {settings.display_media ? (
{data.videos.length ? <Videos urls={data.videos} /> : null} <>
{event.meta?.images.length ? (
<Images urls={event.meta.images} />
) : null}
{event.meta?.videos.length ? (
<Videos urls={event.meta.videos} />
) : null}
</>
) : null}
</div> </div>
); );
} }
@@ -1,13 +1,4 @@
import type { Settings } from "@lume/types"; import { cn } from "@lume/utils";
import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
cn,
} from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { type ReactNode, useMemo } from "react"; import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
@@ -19,135 +10,85 @@ import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteContentLarge({ export function NoteContentLarge({
compact = true,
className, className,
}: { }: {
compact?: boolean;
className?: string; className?: string;
}) { }) {
const { settings }: { settings: Settings } = useRouteContext({
strict: false,
});
const event = useNoteContext(); const event = useNoteContext();
const content = useMemo(() => { const content = useMemo(() => {
const text = event.content.trim();
const words = text.split(/( |\n)/);
// @ts-ignore, kaboom !!!
let parsedContent: ReactNode[] = compact
? text.replace(/\n\s*\n/g, "\n")
: text;
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try { try {
if (hashtags.length) { // Get parsed meta
for (const hashtag of hashtags) { const { images, videos, hashtags, events, mentions } = event.meta;
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
if (events.length) { // Define rich content
for (const event of events) { let richContent: ReactNode[] | string = event.content;
parsedContent = reactStringReplace(
parsedContent,
event,
(match, i) => <MentionNote key={match + i} eventId={event} />,
);
}
}
if (mentions.length) { for (const hashtag of hashtags) {
for (const mention of mentions) { const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace( richContent = reactStringReplace(richContent, regex, () => (
parsedContent, <Hashtag key={nanoid()} tag={hashtag} />
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/gi,
(match, i) => {
try {
const url = new URL(match);
const ext = url.pathname.split(".")[1];
if (!settings.enhancedPrivacy) {
if (IMAGES.includes(ext)) {
return <ImagePreview key={match + i} url={url.toString()} />;
}
if (VIDEOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
if (AUDIOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
}
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
} catch {
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
}
},
);
if (compact) {
parsedContent = reactStringReplace(parsedContent, /\n*\n/g, () => (
<div key={nanoid()} className="h-1.5" />
)); ));
} }
parsedContent = reactStringReplace( for (const event of events) {
parsedContent, richContent = reactStringReplace(richContent, event, (match, i) => (
/[\r]?\n[\r]?\n/g, <MentionNote key={match + i} eventId={event} />
(_, index) => <br key={event.id + "_br_" + index} />, ));
}
for (const mention of mentions) {
richContent = reactStringReplace(richContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={mention} />
));
}
for (const image of images) {
richContent = reactStringReplace(richContent, image, (match, i) => (
<ImagePreview key={match + i} url={match} />
));
}
for (const video of videos) {
richContent = reactStringReplace(richContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
}
richContent = reactStringReplace(
richContent,
/(https?:\/\/\S+)/gi,
(match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-500 line-clamp-1 hover:text-blue-600"
>
{match}
</a>
),
); );
return parsedContent; richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
<div key={nanoid()} className="h-3" />
));
return richContent;
} catch (e) { } catch (e) {
return text; console.log("[parser]: ", e);
return event.content;
} }
}, []); }, [event.content]);
return ( return (
<div className={cn("select-text", className)}> <div
<div className="text-[15px] text-balance content-break leading-normal"> className={cn(
{content} "select-text leading-normal text-pretty content-break",
</div> className,
)}
>
{content}
</div> </div>
); );
} }
@@ -1,4 +1,3 @@
import { NoteActivity } from "./activity";
import { NoteOpenThread } from "./buttons/open"; import { NoteOpenThread } from "./buttons/open";
import { NoteReply } from "./buttons/reply"; import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost"; import { NoteRepost } from "./buttons/repost";
@@ -23,5 +22,4 @@ export const Note = {
Zap: NoteZap, Zap: NoteZap,
Open: NoteOpenThread, Open: NoteOpenThread,
Child: NoteChild, Child: NoteChild,
Activity: NoteActivity,
}; };
@@ -1,13 +1,10 @@
export function Hashtag({ tag }: { tag: string }) { export function Hashtag({ tag }: { tag: string }) {
return ( return (
<button <span className="leading-normal break-all cursor-default group text-start">
type="button"
className="break-all cursor-default leading-normal group"
>
<span className="text-blue-500">#</span> <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"> <span className="underline underline-offset-1 decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
{tag.replace("#", "")} {tag.replace("#", "")}
</span> </span>
</button> </span>
); );
} }
@@ -1,6 +1,5 @@
import { useEvent } from "@lume/ark"; import { LumeWindow, useEvent } from "@lume/system";
import { LinkIcon } from "@lume/icons"; import { LinkIcon } from "@lume/icons";
import { useRouteContext } from "@tanstack/react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { User } from "@/components/user"; import { User } from "@/components/user";
@@ -13,13 +12,12 @@ export function MentionNote({
eventId: string; eventId: string;
openable?: boolean; openable?: boolean;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation(); const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) { if (isLoading) {
return ( return (
<div className="mt-2 w-full flex h-20 items-center justify-center rounded-xl border border-black/10 dark:border-white/10"> <div className="flex items-center justify-center w-full h-20 mt-2 border rounded-xl border-black/10 dark:border-white/10">
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
); );
@@ -27,18 +25,18 @@ export function MentionNote({
if (isError || !data) { if (isError || !data) {
return ( return (
<div className="mt-2 w-full rounded-xl border border-black/10 p-3 dark:border-white/10"> <div className="w-full p-3 mt-2 border rounded-xl border-black/10 dark:border-white/10">
{t("note.error")} {t("note.error")}
</div> </div>
); );
} }
return ( return (
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10"> <div className="flex flex-col w-full border rounded-lg cursor-default border-black/10 dark:border-white/10">
<User.Provider pubkey={data.pubkey}> <User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-12 items-center gap-2 px-3"> <User.Root className="flex items-center gap-2 px-3 h-11">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" /> <User.Avatar className="object-cover rounded-full size-6 shrink-0" />
<div className="inline-flex flex-1 items-center gap-2"> <div className="inline-flex items-center flex-1 gap-2">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" /> <User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<span className="text-neutral-600 dark:text-neutral-400">·</span> <span className="text-neutral-600 dark:text-neutral-400">·</span>
<User.Time <User.Time
@@ -50,21 +48,21 @@ export function MentionNote({
</User.Provider> </User.Provider>
<div <div
className={cn( 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" : "", data.content.length > 400 ? "max-h-[150px] gradient-mask-b-0" : "",
)} )}
> >
{data.content} {data.content}
</div> </div>
{openable ? ( {openable ? (
<div className="flex h-14 items-center justify-end px-3"> <div className="flex items-center justify-end px-2 h-11">
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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 inline-flex items-center justify-center gap-1 text-sm rounded-full h-7 w-28 bg-black/10 dark:bg-white/10 text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
> >
View post View post
<LinkIcon className="size-4" /> <LinkIcon className="size-4" />
@@ -1,24 +1,20 @@
import { useProfile } from "@lume/ark"; import { LumeWindow, useProfile } from "@lume/system";
import { displayNpub } from "@lume/utils"; import { displayNpub } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
export function MentionUser({ pubkey }: { pubkey: string }) { export function MentionUser({ pubkey }: { pubkey: string }) {
const { ark } = useRouteContext({ strict: false });
const { isLoading, isError, profile } = useProfile(pubkey); const { isLoading, isError, profile } = useProfile(pubkey);
return ( return (
<button <button
type="button" type="button"
onClick={() => ark.open_profile(pubkey)} onClick={() => LumeWindow.openProfile(pubkey)}
className="break-words text-start text-blue-500 hover:text-blue-600" className="break-words text-start text-blue-500 hover:text-blue-600"
> >
{isLoading {isLoading
? "@anon" ? "@anon"
: isError : isError
? displayNpub(pubkey, 16) ? displayNpub(pubkey, 16)
: `@${ : `@${profile?.name || profile?.display_name || "anon"}`}
profile?.name || profile?.display_name || profile?.name || "anon"
}`}
</button> </button>
); );
} }
+48 -94
View File
@@ -1,108 +1,62 @@
import { HorizontalDotsIcon } from "@lume/icons"; 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 { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { useCallback } from "react";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
export function NoteMenu() { export function NoteMenu() {
const event = useNoteContext(); const event = useNoteContext();
const { ark } = useRouteContext({ strict: false }); const showContextMenu = useCallback(async (e: React.MouseEvent) => {
const { t } = useTranslation(); e.preventDefault();
const copyID = async () => { const menuItems = await Promise.all([
await writeText(await ark.event_to_bech32(event.id, [""])); MenuItem.new({
toast.success("Copied"); text: "Copy Sharable Link",
}; action: async () => {
const eventId = await event.idAsBech32();
await writeText(`https://njump.me/${eventId}`);
},
}),
MenuItem.new({
text: "Copy Event ID",
action: async () => {
const eventId = await event.idAsBech32();
await writeText(eventId);
},
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Copy Raw Event",
action: async () => {
event.meta = undefined;
const raw = JSON.stringify(event);
await writeText(raw);
},
}),
]);
const copyRaw = async () => { const menu = await Menu.new({
await writeText(JSON.stringify(event)); items: menuItems,
toast.success("Copied"); });
};
const copyNpub = async () => { await menu.popup().catch((e) => console.error(e));
await writeText(await ark.user_to_bech32(event.pubkey, [""])); }, []);
toast.success("Copied");
};
const copyLink = async () => {
await writeText(
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
toast.success("Copied");
};
return ( return (
<DropdownMenu.Root> <button
<DropdownMenu.Trigger asChild> type="button"
<button onClick={(e) => showContextMenu(e)}
type="button" className="inline-flex items-center justify-center group size-7 text-neutral-600 dark:text-neutral-400"
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400" >
> <HorizontalDotsIcon className="size-5" />
<HorizontalDotsIcon className="size-5" /> </button>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_event(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")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
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.copyLink")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
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.copyNoteId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyNpub()}
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.copyAuthorId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
onClick={() => ark.open_profile(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")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyRaw()}
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.copyRaw")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
); );
} }
@@ -1,61 +1,48 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons"; import { useRouteContext } from "@tanstack/react-router";
import { downloadDir } from "@tauri-apps/api/path"; import { open } from "@tauri-apps/plugin-shell";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { useMemo } from "react";
import { download } from "@tauri-apps/plugin-upload";
import { type SyntheticEvent, useState } from "react";
export function ImagePreview({ url }: { url: string }) { export function ImagePreview({ url }: { url: string }) {
const [downloaded, setDownloaded] = useState(false); const { settings } = useRouteContext({ strict: false });
const downloadImage = async (e: { stopPropagation: () => void }) => { const imageUrl = useMemo(() => {
try { if (settings.image_resize_service.length) {
e.stopPropagation(); const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
return newUrl;
const downloadDirPath = await downloadDir(); } else {
const filename = url.substring(url.lastIndexOf("/") + 1); return url;
await download(url, `${downloadDirPath}/${filename}`);
setDownloaded(true);
} catch (e) {
console.error(e);
} }
}; }, [settings.image_resize_service]);
const open = async () => { if (!settings.display_media) {
const name = new URL(url).pathname.split("/").pop(); return (
return new WebviewWindow("image-viewer", { <a
url, href={url}
title: name, target="_blank"
}); rel="noreferrer"
}; className="inline text-blue-500 hover:text-blue-600"
>
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => { {url}
event.currentTarget.src = "/fallback-image.jpg"; </a>
}; );
}
return ( return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> <div className="my-1">
<div onClick={() => open()} className="group relative my-1">
<img <img
src={url} src={imageUrl}
alt={url} alt={url}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
onError={fallback}
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)}
onKeyDown={() => 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> </div>
); );
} }
@@ -1,62 +1,179 @@
import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { Carousel, CarouselItem } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { open } from "@tauri-apps/plugin-shell";
import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useMemo, useState } from "react";
export function Images({ urls }: { urls: string[] }) { export function Images({ urls }: { urls: string[] }) {
const open = async (url: string) => { const { settings } = useRouteContext({ strict: false });
const name = new URL(url).pathname
.split("/")
.pop()
.replace(/[^a-zA-Z ]/g, "");
const label = `viewer-${name}`;
const window = WebviewWindow.getByLabel(label);
if (!window) { const [slidesInView, setSlidesInView] = useState([]);
const newWindow = new WebviewWindow(label, { const [emblaRef, emblaApi] = useEmblaCarousel({
url, dragFree: true,
title: "Image Viewer", align: "start",
width: 800, watchSlides: false,
height: 800, });
titleBarStyle: "overlay",
});
return newWindow; const imageUrls = useMemo(() => {
if (settings.image_resize_service.length) {
let newUrls: string[];
if (urls.length === 1) {
newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
);
} else {
newUrls = urls.map(
(url) =>
`${settings.image_resize_service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
);
}
return newUrls;
} else {
return urls;
}
}, [settings.image_resize_service]);
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
}, [emblaApi]);
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const updateSlidesInView = useCallback((emblaApi) => {
setSlidesInView((slidesInView) => {
if (slidesInView.length === emblaApi.slideNodes().length) {
emblaApi.off("slidesInView", updateSlidesInView);
}
const inView = emblaApi
.slidesInView()
.filter((index) => !slidesInView.includes(index));
return slidesInView.concat(inView);
});
}, []);
useEffect(() => {
if (emblaApi && urls.length > 1) {
updateSlidesInView(emblaApi);
emblaApi.on("slidesInView", updateSlidesInView);
emblaApi.on("reInit", updateSlidesInView);
} }
return await window.setFocus(); return () => {
}; emblaApi?.off("slidesInView", updateSlidesInView);
emblaApi?.off("reInit", updateSlidesInView);
};
}, [emblaApi, updateSlidesInView]);
if (urls.length === 1) { if (urls.length === 1) {
return ( return (
<div className="group px-3"> <div className="px-3 group">
<img <img
src={urls[0]} src={imageUrls[0]}
alt={urls[0]} alt={urls[0]}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(urls[0])} onClick={() => urls[0]}
onKeyDown={() => urls[0]}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/> />
</div> </div>
); );
} }
return ( return (
<Carousel <div className="relative pl-2 overflow-hidden group">
items={urls} <div ref={emblaRef} className="w-full">
renderItem={({ item, isSnapPoint }) => ( <div className="flex w-full gap-2 scrollbar-none">
<CarouselItem key={item} isSnapPoint={isSnapPoint}> {imageUrls.map((url, index) => (
<img <LazyImage
src={item} /* biome-ignore lint/suspicious/noArrayIndexKey: url can be duplicated */
alt={item} key={url + index}
loading="lazy" url={url}
decoding="async" inView={slidesInView.indexOf(index) > -1}
style={{ contentVisibility: "auto" }} />
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" ))}
onClick={() => open(item)} </div>
/> </div>
</CarouselItem> <div
)} aria-hidden
/> className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2"
>
<button
type="button"
disabled={!emblaApi?.canScrollPrev}
className={cn(
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
!emblaApi?.canScrollPrev ? "opacity-50" : "",
)}
onClick={() => scrollPrev()}
>
<ArrowLeftIcon className="size-6" />
</button>
<button
type="button"
disabled={!emblaApi?.canScrollNext}
className={cn(
"size-11 rounded-full bg-black/30 backdrop-blur-sm flex items-center justify-center text-white",
!emblaApi?.canScrollNext ? "opacity-50" : "",
)}
onClick={() => scrollNext()}
>
<ArrowRightIcon className="size-6" />
</button>
</div>
</div>
);
}
function LazyImage({ url, inView }: { url: string; inView: boolean }) {
const [hasLoaded, setHasLoaded] = useState(false);
const setLoaded = useCallback(() => {
if (inView) setHasLoaded(true);
}, [inView, setHasLoaded]);
return (
<div className="w-[240px] h-[320px] shrink-0 relative rounded-lg overflow-hidden">
{!hasLoaded ? (
<div className="flex items-center justify-center size-full bg-black/5 dark:bg-white/5">
<Spinner className="size-4" />
</div>
) : null}
<img
src={
inView
? url
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
}
data-src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(url)}
onKeyDown={() => open(url)}
onLoad={setLoaded}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = "/404.jpg";
}}
/>
</div>
); );
} }
@@ -1,87 +0,0 @@
import { useOpenGraph } from "@lume/utils";
function isImage(url: string) {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
}
export function LinkPreview({ url }: { url: string }) {
const domain = new URL(url);
const { isLoading, isError, data } = useOpenGraph(url);
if (isLoading) {
return (
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</span>
</div>
</div>
);
}
if (!data.title && !data.image && !data.description) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
if (isError) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
loading="lazy"
decoding="async"
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start p-3">
<div className="flex flex-col items-start text-left">
{data.title ? (
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm font-semibold text-blue-500">
{domain.hostname}
</div>
</div>
</a>
);
}
@@ -1,8 +1,25 @@
import { useRouteContext } from "@tanstack/react-router";
export function VideoPreview({ url }: { url: string }) { export function VideoPreview({ url }: { url: string }) {
const { settings } = useRouteContext({ strict: false });
if (settings.display_media) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return ( return (
<div className="my-1 overflow-hidden rounded-xl"> <div className="my-1">
<video <video
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900" className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls controls
muted muted
> >
@@ -1,19 +1,20 @@
import type { Event } from "@lume/types"; import type { LumeEvent } from "@lume/system";
import { type ReactNode, createContext, useContext } from "react"; import { type ReactNode, createContext, useContext } from "react";
const EventContext = createContext<Event>(null); const NoteContext = createContext<LumeEvent>(null);
export function NoteProvider({ export function NoteProvider({
event, event,
children, children,
}: { event: Event; children: ReactNode }) { }: {
return ( event: LumeEvent;
<EventContext.Provider value={event}>{children}</EventContext.Provider> children: ReactNode;
); }) {
return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
} }
export function useNoteContext() { export function useNoteContext() {
const context = useContext(EventContext); const context = useContext(NoteContext);
if (!context) { if (!context) {
throw new Error("Please import Note Provider to use useNoteContext() hook"); throw new Error("Please import Note Provider to use useNoteContext() hook");
} }
+48 -48
View File
@@ -1,62 +1,62 @@
import { LumeWindow } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as HoverCard from "@radix-ui/react-hover-card"; import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { useRouteContext } from "@tanstack/react-router"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useCallback } from "react";
import { User } from "../user"; import { User } from "../user";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
export function NoteUser({ className }: { className?: string }) { export function NoteUser({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext(); const event = useNoteContext();
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(event.pubkey),
}),
MenuItem.new({
text: "Copy Public Key",
action: async () => {
const pubkey = await event.pubkeyAsBech32();
await writeText(pubkey);
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
return ( return (
<User.Provider pubkey={event.pubkey}> <User.Provider pubkey={event.pubkey}>
<HoverCard.Root> <User.Root className={cn("flex items-start justify-between", className)}>
<User.Root <div className="flex w-full gap-2">
className={cn("flex items-start justify-between", className)} <button
> type="button"
<div className="flex w-full gap-2"> onClick={(e) => showContextMenu(e)}
<HoverCard.Trigger className="shrink-0"> className="shrink-0"
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
</HoverCard.Trigger>
<div className="flex w-full items-center gap-3">
<div className="flex items-center gap-1">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 />
</div>
<div className="text-neutral-600 dark:text-neutral-400">·</div>
<User.Time
time={event.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</div>
</User.Root>
<HoverCard.Portal>
<HoverCard.Content
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
sideOffset={5}
side="right"
> >
<div className="flex flex-col gap-2"> <User.Avatar className="object-cover rounded-full size-8 outline outline-1 -outline-offset-1 outline-black/15" />
<User.Avatar className="size-11 rounded-lg object-cover" /> </button>
<div className="flex flex-col gap-2"> <div className="flex items-center w-full gap-3">
<div className="inline-flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" /> <User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 /> <User.NIP05 />
</div>
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
<button
onClick={() => ark.open_profile(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
</button>
</div>
</div> </div>
<HoverCard.Arrow className="fill-black dark:fill-white" /> <div className="text-neutral-600 dark:text-neutral-400">·</div>
</HoverCard.Content> <User.Time
</HoverCard.Portal> time={event.created_at}
</HoverCard.Root> className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</div>
</User.Root>
</User.Provider> </User.Provider>
); );
} }
@@ -1,32 +0,0 @@
import type { Event } from "@lume/types";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
export function Notification({
event,
className,
}: {
event: Event;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className,
)}
>
<div>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" />
</div>
<div className="flex items-center h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
);
}
+5 -9
View File
@@ -1,19 +1,15 @@
import { QuoteIcon } from "@lume/icons"; import { QuoteIcon } from "@lume/icons";
import type { Event } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import type { LumeEvent } from "@lume/system";
export function Quote({ export function Quote({
event, event,
className, className,
}: { }: {
event: Event; event: LumeEvent;
className?: string; className?: string;
}) { }) {
const quoteEventId = event.tags.find(
(tag) => tag[0] === "q" || tag[3] === "mention",
)?.[1];
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
@@ -23,7 +19,7 @@ export function Quote({
)} )}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Note.Child eventId={quoteEventId} isRoot /> <Note.Child event={event.quote} isRoot />
<div className="flex items-center gap-2 px-3"> <div className="flex items-center gap-2 px-3">
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400"> <div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
<QuoteIcon className="size-4" /> <QuoteIcon className="size-4" />
@@ -32,13 +28,13 @@ export function Quote({
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" /> <div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
</div> </div>
<div> <div>
<div className="px-3 h-14 flex items-center justify-between"> <div className="flex items-center justify-between px-3 h-14">
<Note.User /> <Note.User />
</div> </div>
<Note.Content className="px-3" quote={false} clean /> <Note.Content className="px-3" quote={false} clean />
</div> </div>
</div> </div>
<div className="flex items-center h-14 px-3"> <div className="flex items-center px-3 h-14">
<Note.Open /> <Note.Open />
</div> </div>
</Note.Root> </Note.Root>
+35 -39
View File
@@ -1,81 +1,77 @@
import type { Event } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router"; import { type LumeEvent, NostrQuery } from "@lume/system";
export function RepostNote({ export function RepostNote({
event, event,
className, className,
}: { }: {
event: Event; event: LumeEvent;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false }); const { isLoading, isError, data } = useQuery({
const { queryKey: ["event", event.repostId],
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => { queryFn: async () => {
try { try {
if (event.content.length > 50) { const data = await NostrQuery.getRepostEvent(event);
const embed: Event = JSON.parse(event.content); return data;
return embed;
}
const id = event.tags.find((el) => el[0] === "e")?.[1];
const repostEvent = await ark.get_event(id);
return repostEvent;
} catch (e) { } catch (e) {
throw new Error(e); throw new Error(e);
} }
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
}); });
return ( return (
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl mb-3 shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center gap-2 px-3 py-3 border-b border-neutral-100 dark:border-neutral-800/50 rounded-t-xl">
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Reposted by
</div>
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root>
</User.Provider>
{isLoading ? ( {isLoading ? (
<div className="flex h-20 items-center justify-center gap-2"> <div className="flex items-center justify-center h-20 gap-2">
<Spinner /> <Spinner />
Loading event... <span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Loading event...
</span>
</div> </div>
) : isError || !repostEvent ? ( ) : isError || !data ? (
<div className="flex h-20 items-center justify-center"> <div className="flex items-center justify-center h-20">
Event not found within your current relay set Event not found within your current relay set
</div> </div>
) : ( ) : (
<Note.Provider event={repostEvent}> <Note.Provider event={data}>
<Note.Root> <Note.Root>
<div className="px-3 h-14 flex items-center justify-between"> <div className="flex items-center justify-between px-3 h-14">
<Note.User /> <Note.User />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3"> <div className="flex items-center justify-between px-3 mt-3 h-14">
<Note.Open /> <div className="inline-flex items-center gap-3">
<Note.Reply /> <Note.Open />
<Note.Repost /> <Note.Reply />
<Note.Zap /> <Note.Repost />
<Note.Zap />
</div>
<div>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex items-center gap-2">
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
Reposted by
</div>
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root>
</User.Provider>
</div>
</div> </div>
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
+4 -4
View File
@@ -1,12 +1,12 @@
import type { Event } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
export function TextNote({ export function TextNote({
event, event,
className, className,
}: { }: {
event: Event; event: LumeEvent;
className?: string; className?: string;
}) { }) {
return ( return (
@@ -17,12 +17,12 @@ export function TextNote({
className, className,
)} )}
> >
<div className="px-3 h-14 flex items-center justify-between"> <div className="flex items-center justify-between px-3 h-14">
<Note.User /> <Note.User />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Content className="px-3" /> <Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3"> <div className="flex items-center gap-4 px-3 mt-3 h-14">
<Note.Open /> <Note.Open />
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />
+31 -3
View File
@@ -1,5 +1,6 @@
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar"; import * as Avatar from "@radix-ui/react-avatar";
import { useRouteContext } from "@tanstack/react-router";
import { minidenticon } from "minidenticons"; import { minidenticon } from "minidenticons";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useMemo } from "react"; import { useMemo } from "react";
@@ -7,23 +8,50 @@ import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) { export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext(); const user = useUserContext();
const { settings } = useRouteContext({ strict: false });
const picture = useMemo(() => {
if (
settings?.image_resize_service?.length &&
user.profile?.picture?.length
) {
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
return url;
} else {
return user.profile?.picture;
}
}, [user.profile?.picture]);
const fallbackAvatar = useMemo( const fallbackAvatar = useMemo(
() => () =>
`data:image/svg+xml;utf8,${encodeURIComponent( `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50), minidenticon(user.pubkey || nanoid(), 90, 50),
)}`, )}`,
[user], [user.pubkey],
); );
if (settings && !settings.display_avatar) {
return (
<Avatar.Root className="shrink-0">
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
return ( return (
<Avatar.Root className="shrink-0"> <Avatar.Root className="shrink-0">
<Avatar.Image <Avatar.Image
src={user.profile?.picture} src={picture}
alt={user.pubkey} alt={user.pubkey}
loading="eager" loading="eager"
decoding="async" decoding="async"
className={cn("outline-[.5px] outline-black/5", className)} className={cn("outline-[.5px] outline-black/5 object-cover", className)}
/> />
<Avatar.Fallback delayMs={120}> <Avatar.Fallback delayMs={120}>
<img <img
+2 -2
View File
@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
); );
} }
if (user && !user.profile.banner) { if (user && !user.profile?.banner) {
return ( return (
<div <div
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)} className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
return ( return (
<img <img
src={user.profile.banner} src={user?.profile?.banner}
alt="banner" alt="banner"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@@ -1,9 +1,8 @@
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
import { NostrAccount } from "@lume/system";
export function UserFollowButton({ export function UserFollowButton({
simple = false, simple = false,
@@ -12,37 +11,32 @@ export function UserFollowButton({
simple?: boolean; simple?: boolean;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const user = useUserContext(); const user = useUserContext();
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
const toggleFollow = async () => { const toggleFollow = async () => {
setLoading(true); setLoading(true);
if (!followed) {
const add = await ark.follow(user.pubkey); const toggle = await NostrAccount.toggleContact(user.pubkey);
if (add) setFollowed(true);
} else { if (toggle) {
const remove = await ark.unfollow(user.pubkey); setFollowed((prev) => !prev);
if (remove) setFollowed(false); setLoading(false);
} }
setLoading(false);
}; };
useEffect(() => { useEffect(() => {
async function status() { let mounted = true;
setLoading(true);
const contacts = await ark.get_contact_list(); NostrAccount.checkContact(user.pubkey).then((status) => {
if (contacts?.includes(user.pubkey)) { if (mounted) setFollowed(status);
setFollowed(true); });
}
setLoading(false); return () => {
} mounted = false;
status(); };
}, []); }, []);
return ( return (
@@ -56,10 +50,10 @@ export function UserFollowButton({
<Spinner className="size-4" /> <Spinner className="size-4" />
) : followed ? ( ) : followed ? (
!simple ? ( !simple ? (
t("user.unfollow") "Unfollow"
) : null ) : null
) : ( ) : (
t("user.follow") "Follow"
)} )}
</button> </button>
); );
+20 -7
View File
@@ -2,21 +2,34 @@ import { VerifiedIcon } from "@lume/icons";
import { displayLongHandle, displayNpub } from "@lume/utils"; import { displayLongHandle, displayNpub } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
import { NostrQuery } from "@lume/system";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
export function UserNip05() { export function UserNip05() {
const user = useUserContext(); const user = useUserContext();
const { ark } = useRouteContext({ strict: false });
const { isLoading, data: verified } = useQuery({ const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.pubkey], queryKey: ["nip05", user?.pubkey],
queryFn: async () => { queryFn: async () => {
if (!user.profile?.nip05) return false; if (!user.profile?.nip05?.length) 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; return verify;
}, },
enabled: !!user.profile, enabled: !!user.profile?.nip05,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
persister: experimental_createPersister({
storage: localStorage,
maxAge: 1000 * 60 * 60 * 72, // 72 hours
}),
}); });
if (!user.profile?.nip05?.length) return; if (!user.profile?.nip05?.length) return;
@@ -26,7 +39,7 @@ export function UserNip05() {
<Tooltip.Root delayDuration={150}> <Tooltip.Root delayDuration={150}>
<Tooltip.Trigger> <Tooltip.Trigger>
{!isLoading && verified ? ( {!isLoading && verified ? (
<VerifiedIcon className="size-4 text-teal-500" /> <VerifiedIcon className="text-teal-500 size-4" />
) : null} ) : null}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
@@ -1,12 +1,12 @@
import { useProfile } from "@lume/ark"; import { useProfile } from "@lume/system";
import type { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react"; import { type ReactNode, createContext, useContext } from "react";
const UserContext = createContext<{ const UserContext = createContext<{
pubkey: string; pubkey: string;
profile: Metadata;
isError: boolean; isError: boolean;
isLoading: boolean; isLoading: boolean;
profile: Metadata;
}>(null); }>(null);
export function UserProvider({ export function UserProvider({
@@ -21,7 +21,7 @@ export function UserProvider({
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile); const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
return ( return (
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}> <UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
{children} {children}
</UserContext.Provider> </UserContext.Provider>
); );
+116 -116
View File
@@ -1,100 +1,68 @@
import { Column } from "@/components/column"; import { Column } from "@/components/column";
import { Toolbar } from "@/components/toolbar"; import { Toolbar } from "@/components/toolbar";
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
import type { EventColumns, LumeColumn } from "@lume/types"; import { NostrQuery } from "@lume/system";
import type { ColumnEvent, LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs"; import useEmblaCarousel from "embla-carousel-react";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { VList, type VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({ export const Route = createFileRoute("/$account/home")({
loader: async ({ context }) => { loader: async () => {
try { const columns = await NostrQuery.getColumns();
const userColumns = await context.ark.get_columns(); return 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));
}
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const userSavedColumns = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null);
const { account } = Route.useParams(); const { account } = Route.useParams();
const { ark } = Route.useRouteContext(); const initialColumnList = Route.useLoaderData();
const [selectedIndex, setSelectedIndex] = useState(-1); const [columns, setColumns] = useState<LumeColumn[]>([]);
const [columns, setColumns] = useState([]); const [emblaRef, emblaApi] = useEmblaCarousel({
const [isScroll, setIsScroll] = useState(false); watchDrag: false,
const [isResize, setIsResize] = useState(false); loop: false,
});
const goLeft = () => { const scrollPrev = useCallback(() => {
const prevIndex = Math.max(selectedIndex - 1, 0); if (emblaApi) emblaApi.scrollPrev(true);
setSelectedIndex(prevIndex); }, [emblaApi]);
vlistRef.current.scrollToIndex(prevIndex, {
align: "center",
});
};
const goRight = () => { const scrollNext = useCallback(() => {
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1); if (emblaApi) emblaApi.scrollNext(true);
setSelectedIndex(nextIndex); }, [emblaApi]);
vlistRef.current.scrollToIndex(nextIndex, {
align: "center",
});
};
const add = useDebouncedCallback((column: LumeColumn) => { const emitScrollEvent = useCallback(() => {
// update col label getCurrent().emit("child-webview", { scroll: true });
column.label = `${column.label}-${nanoid()}`; }, []);
// create new cols const emitResizeEvent = useCallback(() => {
const cols = [...columns]; getCurrent().emit("child-webview", { resize: true, direction: "x" });
const openColIndex = cols.findIndex((col) => col.label === "open"); }, []);
const newCols = [
...cols.slice(0, openColIndex),
column,
...cols.slice(openColIndex),
];
setColumns(newCols); const openLumeStore = useDebouncedCallback(async () => {
setSelectedIndex(newCols.length); await getCurrent().emit("columns", {
setIsScroll(true); type: "add",
column: {
// scroll to the newest column label: "store",
vlistRef.current.scrollToIndex(newCols.length - 1, { name: "Store",
align: "end", content: "/store/official",
},
}); });
}, 150); }, 150);
const add = useDebouncedCallback((column: LumeColumn) => {
column.label = `${column.label}-${nanoid()}`; // update col label
setColumns((prev) => [column, ...prev]);
}, 150);
const remove = useDebouncedCallback((label: string) => { const remove = useDebouncedCallback((label: string) => {
const newCols = columns.filter((t) => t.label !== label); setColumns((prev) => prev.filter((t) => t.label !== label));
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the first column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "start",
});
}, 150); }, 150);
const updateName = useDebouncedCallback((label: string, title: string) => { const updateName = useDebouncedCallback((label: string, title: string) => {
@@ -109,76 +77,108 @@ function Screen() {
setColumns(newCols); setColumns(newCols);
}, 150); }, 150);
const startResize = useDebouncedCallback( const reset = useDebouncedCallback(() => setColumns([]), 150);
() => setIsResize((prev) => !prev),
150, const handleKeyDown = useDebouncedCallback((event) => {
); if (event.defaultPrevented) return;
switch (event.code) {
case "ArrowLeft":
if (emblaApi) emblaApi.scrollPrev(true);
break;
case "ArrowRight":
if (emblaApi) emblaApi.scrollNext(true);
break;
default:
break;
}
event.preventDefault();
}, 150);
useEffect(() => { useEffect(() => {
setColumns(userSavedColumns); if (emblaApi) {
}, [userSavedColumns]); emblaApi.on("scroll", emitScrollEvent);
emblaApi.on("resize", emitResizeEvent);
emblaApi.on("slidesChanged", emitScrollEvent);
}
return () => {
emblaApi?.off("scroll", emitScrollEvent);
emblaApi?.off("resize", emitResizeEvent);
emblaApi?.off("slidesChanged", emitScrollEvent);
};
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
useEffect(() => { useEffect(() => {
// save state if (columns?.length) {
ark.set_columns(columns); NostrQuery.setColumns(columns).then(() => console.log("saved"));
}
}, [columns]); }, [columns]);
useEffect(() => { useEffect(() => {
const unlistenColEvent = listen<EventColumns>("columns", (data) => { setColumns(initialColumnList);
}, [initialColumnList]);
// Listen for keyboard event
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown]);
// Listen for columns event
useEffect(() => {
const unlisten = listen<ColumnEvent>("columns", (data) => {
if (data.payload.type === "reset") reset();
if (data.payload.type === "add") add(data.payload.column); if (data.payload.type === "add") add(data.payload.column);
if (data.payload.type === "remove") remove(data.payload.label); if (data.payload.type === "remove") remove(data.payload.label);
if (data.payload.type === "set_title") if (data.payload.type === "set_title")
updateName(data.payload.label, data.payload.title); updateName(data.payload.label, data.payload.title);
}); });
const unlistenWindowResize = getCurrent().listen("tauri://resize", () => {
startResize();
});
return () => { return () => {
unlistenColEvent.then((f) => f()); unlisten.then((f) => f());
unlistenWindowResize.then((f) => f());
}; };
}, []); }, []);
return ( return (
<div className="h-full w-full"> <div className="size-full">
<VList <div ref={emblaRef} className="overflow-hidden size-full">
ref={vlistRef} <div className="flex size-full">
horizontal {columns?.map((column) => (
tabIndex={-1} <Column
itemSize={440} key={account + column.label}
overscan={3} column={column}
onScroll={() => setIsScroll(true)} account={account}
onScrollEnd={() => setIsScroll(false)} />
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none" ))}
cache={null} </div>
> </div>
{columns.map((column) => (
<Column
key={column.label}
column={column}
account={account}
isScroll={isScroll}
isResize={isResize}
/>
))}
</VList>
<Toolbar> <Toolbar>
<div className="flex items-center gap-1"> <div className="flex items-center h-8 gap-1 p-[2px] rounded-full bg-black/5 dark:bg-white/5">
<button <button
type="button" type="button"
onClick={() => goLeft()} onClick={() => scrollPrev()}
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" className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
> >
<ArrowLeftIcon className="size-5" /> <ArrowLeftIcon className="size-4" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => goRight()} onClick={() => openLumeStore()}
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" className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
> >
<ArrowRightIcon className="size-5" /> <PlusSquareIcon className="size-4" />
</button>
<button
type="button"
onClick={() => scrollNext()}
className="inline-flex items-center justify-center rounded-full size-7 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-4" />
</button> </button>
</div> </div>
</Toolbar> </Toolbar>
+77 -120
View File
@@ -1,41 +1,28 @@
import {
BellIcon,
ComposeFilledIcon,
HorizontalDotsIcon,
PlusIcon,
SearchIcon,
} from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { import { ComposeFilledIcon, HorizontalDotsIcon, PlusIcon } from "@lume/icons";
cn, import { LumeWindow, NostrAccount } from "@lume/system";
decodeZapInvoice, import { cn } from "@lume/utils";
displayNpub,
sendNativeNotification,
} 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 * as Popover from "@radix-ui/react-popover";
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useState } from "react";
export const Route = createFileRoute("/$account")({ export const Route = createFileRoute("/$account")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const accounts = await NostrAccount.getAccounts();
const accounts = await ark.get_accounts();
return { accounts }; return { accounts };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, platform } = Route.useRouteContext(); const { platform } = Route.useRouteContext();
const navigate = Route.useNavigate();
return ( return (
<div className="flex h-screen w-screen flex-col"> <div className="flex flex-col w-screen h-screen">
<div <div
data-tauri-drag-region data-tauri-drag-region
className={cn( className={cn(
@@ -45,31 +32,22 @@ function Screen() {
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Accounts /> <Accounts />
<button <Link
type="button" to="/landing"
onClick={() => navigate({ to: "/landing/" })} className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20"
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" /> <PlusIcon className="size-5" />
</button> </Link>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="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" className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium text-white bg-blue-500 rounded-full w-max hover:bg-blue-600"
> >
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
New Post New Post
</button> </button>
<Bell />
<button
type="button"
onClick={() => ark.open_search()}
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" />
</button>
<div id="toolbar" /> <div id="toolbar" />
</div> </div>
</div> </div>
@@ -81,12 +59,12 @@ function Screen() {
} }
function Accounts() { function Accounts() {
const { ark, accounts } = Route.useRouteContext(); const navigate = Route.useNavigate();
const { accounts } = Route.useRouteContext();
const { account } = Route.useParams(); const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null); const [windowWidth, setWindowWidth] = useState<number>(null);
const navigate = Route.useNavigate();
const sortedList = useMemo(() => { const sortedList = useMemo(() => {
const list = accounts; const list = accounts;
@@ -100,18 +78,51 @@ function Accounts() {
return list; return list;
}, [accounts]); }, [accounts]);
const changeAccount = async (npub: string) => { const showContextMenu = useCallback(
async (e: React.MouseEvent, npub: string) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(npub),
}),
MenuItem.new({
text: "Open Settings",
action: () => LumeWindow.openSettings(),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
},
[],
);
const changeAccount = async (e: React.MouseEvent, npub: string) => {
if (npub === account) { if (npub === account) {
return await ark.open_profile(account); return showContextMenu(e, npub);
} }
// change current account and update signer // Change current account and update signer
const select = await ark.load_account(npub); const select = await NostrAccount.loadAccount(npub);
if (select) { 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 { } else {
toast.warning("Something wrong."); await message("Something wrong.", { title: "Accounts", kind: "error" });
} }
}; };
@@ -128,10 +139,15 @@ function Accounts() {
setWindowWidth(getWindowDimensions().width); setWindowWidth(getWindowDimensions().width);
} }
if (!windowWidth) setWindowWidth(getWindowDimensions().width); if (!windowWidth) {
setWindowWidth(getWindowDimensions().width);
}
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); return () => {
window.removeEventListener("resize", handleResize);
};
}, []); }, []);
return ( return (
@@ -139,7 +155,11 @@ function Accounts() {
{sortedList {sortedList
.slice(0, windowWidth > 500 ? account.length : 2) .slice(0, windowWidth > 500 ? account.length : 2)
.map((user) => ( .map((user) => (
<button key={user} type="button" onClick={() => changeAccount(user)}> <button
key={user}
type="button"
onClick={(e) => changeAccount(e, user)}
>
<User.Provider pubkey={user}> <User.Provider pubkey={user}>
<User.Root <User.Root
className={cn( className={cn(
@@ -161,21 +181,21 @@ function Accounts() {
))} ))}
{accounts.length >= 3 && windowWidth <= 700 ? ( {accounts.length >= 3 && windowWidth <= 700 ? (
<Popover.Root> <Popover.Root>
<Popover.Trigger 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"> <Popover.Trigger className="inline-flex items-center justify-center rounded-full size-8 shrink-0 bg-black/10 text-neutral-800 hover:bg-black/20 dark:bg-white/10 dark:text-neutral-200 dark:hover:bg-white/20">
<HorizontalDotsIcon className="size-5" /> <HorizontalDotsIcon className="size-5" />
</Popover.Trigger> </Popover.Trigger>
<Popover.Portal> <Popover.Portal>
<Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-neutral-950 p-1 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950"> <Popover.Content className="flex h-11 select-none items-center justify-center rounded-md bg-black/20 p-1 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
{sortedList.slice(2).map((user) => ( {sortedList.slice(2).map((user) => (
<button <button
key={user} key={user}
type="button" type="button"
onClick={() => changeAccount(user)} onClick={(e) => changeAccount(e, user)}
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md" className="inline-flex items-center justify-center rounded-md size-9 hover:bg-white/10"
> >
<User.Provider pubkey={user}> <User.Provider pubkey={user}>
<User.Root className="rounded-full ring-1 ring-white/10"> <User.Root className="rounded-full ring-1 ring-white/10">
<User.Avatar className="size-7 aspect-square h-auto rounded-full object-cover" /> <User.Avatar className="object-cover h-auto rounded-full size-7 aspect-square" />
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</button> </button>
@@ -188,66 +208,3 @@ function Accounts() {
</div> </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>
);
}
+4 -34
View File
@@ -1,60 +1,30 @@
import type { Ark } from "@lume/ark"; import type { Settings } from "@lume/system";
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
import type { Interests, Metadata, Settings } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router"; import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { Platform } from "@tauri-apps/plugin-os"; 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 { interface RouterContext {
// System // System
ark: Ark;
queryClient: QueryClient; queryClient: QueryClient;
// App info // App info
platform?: Platform; platform?: Platform;
locale?: string; locale?: string;
// Settings // Settings
settings?: Settings; settings?: Settings;
interests?: Interests; // Accounts
// Profile
accounts?: string[]; accounts?: string[];
profile?: Metadata;
isNewUser?: boolean;
// Editor
initialValue?: EditorElement[];
} }
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({
component: () => ( component: () => <Outlet />,
<>
<Toaster
position="bottom-right"
icons={{
success: <CheckCircleIcon className="size-5" />,
info: <InfoCircleIcon className="size-5" />,
error: <CancelCircleIcon className="size-5" />,
}}
closeButton
theme="system"
/>
<Outlet />
</>
),
pendingComponent: Pending, pendingComponent: Pending,
wrapInSuspense: true, wrapInSuspense: true,
}); });
function Pending() { function Pending() {
return ( return (
<div className="flex h-screen w-screen flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center w-screen h-screen">
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
); );
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -1,26 +1,20 @@
import { CheckIcon } from "@lume/icons"; import { CheckIcon } from "@lume/icons";
import type { AppRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { displayNsec } from "@lume/utils"; import { displayNsec } from "@lume/utils";
import * as Checkbox from "@radix-ui/react-checkbox"; import * as Checkbox from "@radix-ui/react-checkbox";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/backup")({ export const Route = createFileRoute("/auth/$account/backup")({
validateSearch: (search: Record<string, string>): AppRouteSearch => {
return {
account: search.account,
};
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { account } = Route.useSearch(); const { account } = Route.useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const [key, setKey] = useState(null); const [key, setKey] = useState(null);
@@ -35,13 +29,13 @@ function Screen() {
try { try {
if (key) { if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) { if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
return toast.warning("You need to confirm before continue"); return await message("You need to confirm before continue", {
title: "Backup",
kind: "info",
});
} }
return navigate({ navigate({ to: "/", replace: true });
to: "/auth/settings",
search: { account },
});
} }
// start loading // start loading
@@ -57,7 +51,10 @@ function Screen() {
}); });
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Backup",
kind: "error",
});
} }
}; };
@@ -66,12 +63,15 @@ function Screen() {
await writeText(key); await writeText(key);
setCopied(true); setCopied(true);
} catch (e) { } catch (e) {
toast.error(e); await message(String(e), {
title: "Backup",
kind: "error",
});
} }
}; };
return ( return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> <div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="flex flex-col text-center"> <div className="flex flex-col text-center">
<h3 className="text-xl font-semibold">Backup your sign in keys</h3> <h3 className="text-xl font-semibold">Backup your sign in keys</h3>
<p className="text-neutral-700 dark:text-neutral-300"> <p className="text-neutral-700 dark:text-neutral-300">
@@ -79,7 +79,7 @@ function Screen() {
access to your account if you lose this key. access to your account if you lose this key.
</p> </p>
</div> </div>
<div className="flex w-full flex-col gap-5"> <div className="flex flex-col w-full gap-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="passphase" className="font-medium"> <label htmlFor="passphase" className="font-medium">
Set a passphase to secure your key Set a passphase to secure your key
@@ -90,7 +90,7 @@ function Screen() {
type="password" type="password"
value={passphase} value={passphase}
onChange={(e) => setPassphase(e.target.value)} onChange={(e) => setPassphase(e.target.value)}
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400" className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
</div> </div>
@@ -106,12 +106,12 @@ function Screen() {
type="text" type="text"
value={displayNsec(key, 36)} value={displayNsec(key, 36)}
readOnly readOnly
className="w-full h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400" className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"
onClick={() => copyKey()} onClick={() => copyKey()}
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30" className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
> >
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</button> </button>
@@ -126,7 +126,7 @@ function Screen() {
onCheckedChange={() => onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 })) setConfirm((state) => ({ ...state, c1: !state.c1 }))
} }
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20" className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm1" id="confirm1"
> >
<Checkbox.Indicator className="text-blue-500"> <Checkbox.Indicator className="text-blue-500">
@@ -146,7 +146,7 @@ function Screen() {
onCheckedChange={() => onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 })) setConfirm((state) => ({ ...state, c2: !state.c2 }))
} }
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20" className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm2" id="confirm2"
> >
<Checkbox.Indicator className="text-blue-500"> <Checkbox.Indicator className="text-blue-500">
@@ -166,7 +166,7 @@ function Screen() {
onCheckedChange={() => onCheckedChange={() =>
setConfirm((state) => ({ ...state, c3: !state.c3 })) setConfirm((state) => ({ ...state, c3: !state.c3 }))
} }
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-white/10 dark:hover:bg-white/20" className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm3" id="confirm3"
> >
<Checkbox.Indicator className="text-blue-500"> <Checkbox.Indicator className="text-blue-500">
@@ -189,7 +189,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={loading} disabled={loading}
className="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" className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : t("global.continue")} {loading ? <Spinner /> : t("global.continue")}
</button> </button>
@@ -1,26 +1,27 @@
import { AvatarUploader } from "@/components/avatarUploader"; import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons"; import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({ export const Route = createFileRoute("/auth/create-profile")({
component: Screen, component: Screen,
loader: ({ context }) => { loader: async () => {
return context.ark.create_keys(); const account = await NostrAccount.createAccount();
return account;
}, },
}); });
function Screen() { function Screen() {
const keys = Route.useLoaderData(); const account = Route.useLoaderData();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const { ark } = Route.useRouteContext();
const { register, handleSubmit } = useForm(); const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>(""); const [picture, setPicture] = useState<string>("");
@@ -35,46 +36,46 @@ function Screen() {
try { try {
// Save account keys // Save account keys
const save = await ark.save_account(keys.nsec); const save = await NostrAccount.saveAccount(account.nsec);
// Then create profile // Then create profile
if (save) { if (save) {
const profile: Metadata = { ...data, picture }; const profile: Metadata = { ...data, picture };
const eventId = await ark.create_profile(profile); const eventId = await NostrAccount.createProfile(profile);
if (eventId) { if (eventId) {
navigate({ navigate({
to: "/auth/new/backup", to: "/auth/$account/backup",
search: { account: keys.npub }, params: { account: account.npub },
replace: true, replace: true,
}); });
} }
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), { title: "Create Profile", kind: "error" });
} }
}; };
return ( return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> <div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center"> <div className="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3> <h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div> </div>
<div> <div>
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200"> <div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? ( {picture ? (
<img <img
src={picture} src={picture}
alt="avatar" alt="avatar"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover" className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/> />
) : null} ) : null}
<AvatarUploader <AvatarUploader
setPicture={setPicture} setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full dark:text-black bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
<PlusIcon className="size-8" /> <PlusIcon className="size-8" />
</AvatarUploader> </AvatarUploader>
@@ -82,7 +83,7 @@ function Screen() {
</div> </div>
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col gap-3" className="flex flex-col w-full gap-3"
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium"> <label htmlFor="display_name" className="font-medium">
@@ -93,7 +94,7 @@ function Screen() {
{...register("display_name", { required: true, minLength: 1 })} {...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland" placeholder="e.g. Alice in Nostrland"
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400" className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -105,7 +106,7 @@ function Screen() {
{...register("name")} {...register("name")}
placeholder="e.g. alice" placeholder="e.g. alice"
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400" className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -128,12 +129,12 @@ function Screen() {
{...register("website")} {...register("website")}
placeholder="e.g. https://alice.me" placeholder="e.g. https://alice.me"
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400" className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="mt-3 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" className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : t("global.continue")} {loading ? <Spinner /> : t("global.continue")}
</button> </button>
@@ -1,14 +1,14 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/privkey")({ export const Route = createLazyFileRoute("/auth/import")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const [key, setKey] = useState(""); const [key, setKey] = useState("");
@@ -16,35 +16,33 @@ function Screen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!key.startsWith("nsec1")) if (!key.startsWith("nsec1")) {
return toast.warning( return await message(
"You need to enter a valid private key starts with nsec or ncryptsec", "You need to enter a valid private key starts with nsec or ncryptsec",
{ title: "Import Key", kind: "info" },
); );
}
try { try {
setLoading(true); setLoading(true);
const npub = await ark.save_account(key, password); const npub = await NostrAccount.saveAccount(key, password);
if (npub) { if (npub) {
navigate({ navigate({ to: "/", replace: true });
to: "/auth/settings",
search: { account: npub },
replace: true,
});
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(e); await message(String(e), { title: "Import Key", kind: "error" });
} }
}; };
return ( return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> <div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center"> <div className="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3> <h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div> </div>
<div className="flex w-full flex-col gap-3"> <div className="flex flex-col w-full gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label
htmlFor="key" htmlFor="key"
@@ -58,7 +56,7 @@ function Screen() {
placeholder="nsec or ncryptsec..." placeholder="nsec or ncryptsec..."
value={key} value={key}
onChange={(e) => setKey(e.target.value)} onChange={(e) => setKey(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400" className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -73,14 +71,14 @@ function Screen() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400" className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={loading} disabled={loading}
className="mt-3 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" className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : "Login"} {loading ? <Spinner /> : "Login"}
</button> </button>
+16 -18
View File
@@ -1,49 +1,47 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/remote")({ export const Route = createLazyFileRoute("/auth/remote")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const [uri, setUri] = useState(""); const [uri, setUri] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!uri.startsWith("bunker://")) if (!uri.startsWith("bunker://")) {
return toast.warning( return await message(
"You need to enter a valid Connect URI starts with bunker://", "You need to enter a valid Connect URI starts with bunker://",
{ title: "Nostr Connect", kind: "info" },
); );
}
try { try {
setLoading(true); setLoading(true);
const npub = await ark.nostr_connect(uri); const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
if (npub) { if (remoteAccount?.length) {
navigate({ navigate({ to: "/", replace: true });
to: "/auth/settings",
search: { account: npub },
replace: true,
});
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(e); await message(String(e), { title: "Nostr Connect", kind: "error" });
} }
}; };
return ( return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl"> <div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="text-center"> <div className="text-center">
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3> <h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
</div> </div>
<div className="flex w-full flex-col gap-3"> <div className="flex flex-col w-full gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label <label
htmlFor="uri" htmlFor="uri"
@@ -57,20 +55,20 @@ function Screen() {
placeholder="bunker://..." placeholder="bunker://..."
value={uri} value={uri}
onChange={(e) => setUri(e.target.value)} onChange={(e) => setUri(e.target.value)}
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400" className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="flex flex-col gap-1 items-center"> <div className="flex flex-col items-center gap-1">
<button <button
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={loading} disabled={loading}
className="mt-3 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" className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner /> : "Login"} {loading ? <Spinner /> : "Login"}
</button> </button>
{loading ? ( {loading ? (
<p className="text-neutral-600 dark:text-neutral-400 text-sm text-center"> <p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
Waiting confirmation... Waiting confirmation...
</p> </p>
) : null} ) : null}
-218
View File
@@ -1,218 +0,0 @@
import { LaurelIcon } from "@lume/icons";
import type { AppRouteSearch, Settings } from "@lume/types";
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 { 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 },
};
},
component: Screen,
pendingComponent: Pending,
});
function Screen() {
const navigate = useNavigate();
const { account } = Route.useSearch();
const { t } = useTranslation();
const { ark, settings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(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 toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !newSettings.zap,
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const submit = async () => {
try {
// start loading
setLoading(true);
// publish settings
const eventId = await ark.set_settings(newSettings);
if (eventId) {
return navigate({
to: "/$account/home",
params: { account },
replace: true,
});
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-950 text-teal-500">
<LaurelIcon className="size-8" />
</div>
<div>
<h1 className="text-xl font-semibold">
{t("onboardingSettings.title")}
</h1>
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
{t("onboardingSettings.subtitle")}
</p>
</div>
</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>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume will display external resources like image, video or link
preview as plain text.
</p>
</div>
<Switch.Root
checked={newSettings.enhancedPrivacy}
onClick={() => toggleEnhancedPrivacy()}
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">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>
<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>
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
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">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
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>
<button
type="button"
onClick={() => submit()}
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")}
</button>
</div>
</div>
);
}
function Pending() {
return (
<div className="flex h-full w-full items-center justify-center">
<button type="button" className="size-5" disabled>
<Spinner className="size-5" />
</button>
</div>
);
}
@@ -0,0 +1,131 @@
import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import type { Relay } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
export const Route = createFileRoute("/bootstrap-relays")({
loader: async () => {
const bootstrapRelays = await NostrQuery.getBootstrapRelays();
return bootstrapRelays;
},
component: Screen,
});
function Screen() {
const bootstrapRelays = Route.useLoaderData();
const { register, reset, handleSubmit } = useForm();
const [relays, setRelays] = useState<Relay[]>([]);
const [isLoading, setIsLoading] = useState(false);
const removeRelay = (url: string) => {
setRelays((prev) => prev.filter((relay) => relay.url !== url));
};
const onSubmit = async (data: { url: string; purpose: string }) => {
try {
const relay: Relay = { url: data.url, purpose: data.purpose };
setRelays((prev) => [...prev, relay]);
reset();
} catch (e) {
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
}
};
const save = async () => {
try {
setIsLoading(true);
await NostrQuery.saveBootstrapRelays(relays);
} catch (e) {
await message(String(e), { title: "Bootstrap Relays", kind: "error" });
}
};
useEffect(() => {
setRelays(bootstrapRelays);
}, [bootstrapRelays]);
return (
<div className="flex flex-col items-center justify-center w-screen h-screen">
<div className="w-full max-w-sm mx-auto lg:max-w-lg">
<div className="text-center h-11">
<h1 className="font-semibold">Customize Bootstrap Relays</h1>
</div>
<div className="flex flex-col w-full px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
{relays.map((relay) => (
<div
key={relay.url}
className="flex items-center justify-between h-11"
>
<div className="inline-flex items-center gap-2 text-sm font-medium">
{relay.url}
</div>
<div className="flex items-center gap-2">
{relay.purpose?.length ? (
<button
type="button"
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
{relay.purpose}
</button>
) : null}
<button
type="button"
onClick={() => removeRelay(relay.url)}
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
>
<CancelIcon className="size-3" />
</button>
</div>
</div>
))}
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex items-center w-full gap-2 mb-0"
>
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
<input
{...register("url", {
required: true,
minLength: 1,
})}
name="url"
placeholder="wss://..."
spellCheck={false}
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
/>
<select
{...register("purpose")}
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
>
<option value="read">Read</option>
<option value="write">Write</option>
<option value="">Both</option>
</select>
</div>
<button
type="submit"
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
>
<PlusIcon className="size-7" />
</button>
</form>
</div>
</div>
<button
type="button"
onClick={() => save()}
disabled={isLoading}
className="inline-flex items-center justify-center w-full h-10 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? <Spinner /> : "Save & Relaunch"}
</button>
</div>
</div>
);
}
+28 -27
View File
@@ -1,10 +1,11 @@
import { CancelIcon, CheckCircleIcon, PlusIcon } from "@lume/icons"; import { User } from "@/components/user";
import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrAccount, NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { User } from "@/components/user";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-group")({ export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -14,17 +15,14 @@ export const Route = createFileRoute("/create-group")({
name: search.name, name: search.name,
}; };
}, },
loader: async ({ context }) => { loader: async () => {
const ark = context.ark; const contacts = await NostrAccount.getContactList();
const contacts = await ark.get_contact_list();
return contacts; return contacts;
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [npub, setNpub] = useState(""); const [npub, setNpub] = useState("");
const [users, setUsers] = useState<string[]>([ const [users, setUsers] = useState<string[]>([
@@ -57,32 +55,35 @@ function Screen() {
setIsLoading(true); setIsLoading(true);
const key = `lume_group_${search.label}`; const key = `lume_group_${search.label}`;
const createGroup = await ark.set_nstore(key, JSON.stringify(users)); const createGroup = await NostrQuery.setNstore(
key,
JSON.stringify(users),
);
if (createGroup) { if (createGroup) {
return navigate({ to: search.redirect, search: { ...search } }); return navigate({ to: search.redirect, search: { ...search } });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(e); await message(String(e), { title: "Create Group", kind: "error" });
} }
}; };
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="text-center flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center text-center">
<h1 className="text-2xl font-serif font-medium"> <h1 className="font-serif text-2xl font-medium">
Focus feeds for people you like Focus feeds for people you like
</h1> </h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some people for custom feeds. Add some people for custom feeds.
</p> </p>
</div> </div>
<div className="w-4/5 max-w-full flex flex-col gap-3"> <div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center bg-black/5 dark:bg-white/5 rounded-lg"> <div className="flex items-center w-full rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<label <label
htmlFor="name" htmlFor="name"
className="w-16 border-r border-black/10 dark:border-white/10 shrink-0 text-center text-sm font-semibold" className="w-16 text-sm font-semibold text-center border-r border-black/10 dark:border-white/10 shrink-0"
> >
Name Name
</label> </label>
@@ -91,10 +92,10 @@ function Screen() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a name for this group" placeholder="Enter a name for this group"
className="h-full bg-transparent border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400" className="h-full px-3 text-sm bg-transparent border-none placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<div className="w-full flex flex-col items-center gap-3"> <div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"> <div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] flex flex-col gap-3 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
@@ -102,12 +103,12 @@ function Screen() {
value={npub} value={npub}
onChange={(e) => setNpub(e.target.value)} onChange={(e) => setNpub(e.target.value)}
placeholder="npub1..." placeholder="npub1..."
className="h-9 w-full rounded-lg bg-black/10 dark:bg-white/10 border-none text-sm px-3 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400" className="w-full px-3 text-sm border-none rounded-lg h-9 bg-black/10 dark:bg-white/10 placeholder:text-neutral-600 focus:border-neutral-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
<button <button
type="button" type="button"
onClick={() => addUser()} onClick={() => addUser()}
className="inline-flex size-9 rounded-lg items-center justify-center bg-black/20 dark:bg-white/20 shrink-0 text-white hover:bg-blue-500" className="inline-flex items-center justify-center text-white rounded-lg size-9 bg-black/20 dark:bg-white/20 shrink-0 hover:bg-blue-500"
> >
<PlusIcon className="size-6" /> <PlusIcon className="size-6" />
</button> </button>
@@ -121,11 +122,11 @@ function Screen() {
key={item} key={item}
type="button" type="button"
onClick={() => toggleUser(item)} onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50" className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item}> <User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5"> <User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="object-cover rounded-full size-8" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" /> <User.Name className="text-sm font-medium" />
</div> </div>
@@ -137,7 +138,7 @@ function Screen() {
</button> </button>
)) ))
) : ( ) : (
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg"> <div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
Empty. Empty.
</div> </div>
)} )}
@@ -152,11 +153,11 @@ function Screen() {
key={item} key={item}
type="button" type="button"
onClick={() => toggleUser(item)} onClick={() => toggleUser(item)}
className="inline-flex items-center justify-between px-3 py-2 rounded-lg bg-white dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50" className="inline-flex items-center justify-between px-3 py-2 bg-white rounded-lg dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item}> <User.Provider pubkey={item}>
<User.Root className="flex items-center gap-2.5"> <User.Root className="flex items-center gap-2.5">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="object-cover rounded-full size-8" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="text-sm font-medium" /> <User.Name className="text-sm font-medium" />
</div> </div>
@@ -165,7 +166,7 @@ function Screen() {
</button> </button>
)) ))
) : ( ) : (
<div className="bg-black/5 dark:bg-white/5 text-sm flex items-center justify-center h-14 rounded-lg"> <div className="flex items-center justify-center text-sm rounded-lg bg-black/5 dark:bg-white/5 h-14">
<p> <p>
Find more user at{" "} Find more user at{" "}
<a <a
@@ -186,7 +187,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={isLoading || users.length < 1} disabled={isLoading || users.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
@@ -1,9 +1,9 @@
import { NostrAccount } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/create-newsfeed/f2f")({ export const Route = createFileRoute("/create-newsfeed/f2f")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -24,26 +24,33 @@ function Screen() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!npub.startsWith("npub1")) if (!npub.startsWith("npub1")) {
return toast.warning("You must enter a valid npub."); return await message("You must enter a valid npub.", {
title: "Create Newsfeed",
kind: "info",
});
}
try { try {
setIsLoading(true); setIsLoading(true);
const sync: boolean = await invoke("friend_to_friend", { npub }); const sync = await NostrAccount.f2f(npub);
if (sync) { if (sync) {
return navigate({ to: redirect }); return navigate({ to: redirect });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Newsfeed",
kind: "error",
});
} }
}; };
return ( return (
<div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> <div className="overflow-y-auto scrollbar-none p-2 shrink-0 h-[450px] bg-white dark:bg-white/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
<div className="h-full flex flex-col justify-between"> <div className="flex flex-col justify-between h-full">
<div className="flex-1 flex flex-col gap-1.5 justify-center px-5"> <div className="flex-1 flex flex-col gap-1.5 justify-center px-5">
<p className="font-semibold text-neutral-500"> <p className="font-semibold text-neutral-500">
You already have a friend on Nostr? You already have a friend on Nostr?
@@ -60,7 +67,7 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="npub" className="font-medium text-sm"> <label htmlFor="npub" className="text-sm font-medium">
NPUB NPUB
</label> </label>
<input <input
@@ -69,13 +76,13 @@ function Screen() {
value={npub} value={npub}
onChange={(e) => setNpub(e.target.value)} onChange={(e) => setNpub(e.target.value)}
spellCheck={false} spellCheck={false}
className="h-11 rounded-lg bg-transparent border border-neutral-200 dark:border-neutral-800 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400" className="px-3 bg-transparent border rounded-lg h-11 border-neutral-200 dark:border-neutral-800 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:placeholder:text-neutral-400"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
className="inline-flex items-center justify-center w-full rounded-lg h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600" className="inline-flex items-center justify-center w-full text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
+23 -6
View File
@@ -1,25 +1,38 @@
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link, Outlet } from "@tanstack/react-router"; import { Link, Outlet } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/create-newsfeed")({ export const Route = createFileRoute("/create-newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const search = Route.useSearch();
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="text-center flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center text-center">
<h1 className="text-2xl font-serif font-medium"> <h1 className="font-serif text-2xl font-medium">
Build up your timeline. Build up your timeline.
</h1> </h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Follow some people to keep up to date with them. Follow some people to keep up to date with them.
</p> </p>
</div> </div>
<div className="w-4/5 max-w-full flex flex-col gap-3"> <div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5"> <div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
<Link to="/create-newsfeed/users" className="flex-1 h-8"> <Link
to="/create-newsfeed/users"
search={search}
className="flex-1 h-8"
>
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
@@ -33,7 +46,11 @@ function Screen() {
</div> </div>
)} )}
</Link> </Link>
<Link to="/create-newsfeed/f2f" className="flex-1 h-8"> <Link
to="/create-newsfeed/f2f"
search={search}
className="flex-1 h-8"
>
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
@@ -1,10 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense, useState } from "react";
import { Await, defer } from "@tanstack/react-router";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { Spinner } from "@lume/ui"; import { NostrAccount } from "@lume/system";
import { toast } from "sonner";
import type { ColumnRouteSearch } from "@lume/types"; import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Await, defer } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { Suspense, useState } from "react";
export const Route = createFileRoute("/create-newsfeed/users")({ export const Route = createFileRoute("/create-newsfeed/users")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -31,7 +32,6 @@ export const Route = createFileRoute("/create-newsfeed/users")({
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
const { redirect } = Route.useSearch(); const { redirect } = Route.useSearch();
@@ -52,23 +52,26 @@ function Screen() {
try { try {
setIsLoading(true); setIsLoading(true);
const newContactList = await ark.set_contact_list(follows); const newContactList = await NostrAccount.setContactList(follows);
if (newContactList) { if (newContactList) {
return navigate({ to: redirect }); return navigate({ to: redirect });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Group",
kind: "error",
});
} }
}; };
return ( return (
<div className="w-full flex flex-col items-center gap-3"> <div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"> <div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<Suspense <Suspense
fallback={ fallback={
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex flex-col items-center justify-center w-full h-20 gap-1">
<button <button
type="button" type="button"
className="inline-flex items-center gap-2 text-sm font-medium" className="inline-flex items-center gap-2 text-sm font-medium"
@@ -85,27 +88,27 @@ function Screen() {
users.profiles.map((item: { pubkey: string }) => ( users.profiles.map((item: { pubkey: string }) => (
<div <div
key={item.pubkey} key={item.pubkey}
className="h-max w-full overflow-hidden mb-2 p-2 bg-white dark:bg-black/20 backdrop-blur-lg rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50" className="w-full p-2 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20 backdrop-blur-lg shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<User.Provider pubkey={item.pubkey}> <User.Provider pubkey={item.pubkey}>
<User.Root> <User.Root>
<div className="flex h-full w-full flex-col gap-2"> <div className="flex flex-col w-full h-full gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" /> <User.Avatar className="object-cover rounded-full size-7 shrink-0" />
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" /> <User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
</div> </div>
<button <button
type="button" type="button"
onClick={() => toggleFollow(item.pubkey)} onClick={() => toggleFollow(item.pubkey)}
className="inline-flex h-7 w-20 items-center justify-center rounded-lg bg-black/10 text-sm font-medium hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="inline-flex items-center justify-center w-20 text-sm font-medium rounded-lg h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
{follows.includes(item.pubkey) {follows.includes(item.pubkey)
? "Unfollow" ? "Unfollow"
: "Follow"} : "Follow"}
</button> </button>
</div> </div>
<User.About className="line-clamp-3 max-w-none select-text text-neutral-800 dark:text-neutral-400" /> <User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
@@ -119,7 +122,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={isLoading || follows.length < 1} disabled={isLoading || follows.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
+26 -15
View File
@@ -1,10 +1,16 @@
import { CheckCircleIcon } from "@lume/icons"; import { CheckCircleIcon } from "@lume/icons";
import type { ColumnRouteSearch, Topic } from "@lume/types"; import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { TOPICS } from "@lume/utils"; import { TOPICS } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
type Topic = {
title: string;
content: string[];
};
export const Route = createFileRoute("/create-topic")({ export const Route = createFileRoute("/create-topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -18,8 +24,6 @@ export const Route = createFileRoute("/create-topic")({
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const [topics, setTopics] = useState<Topic[]>([]); const [topics, setTopics] = useState<Topic[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -39,45 +43,52 @@ function Screen() {
setIsLoading(true); setIsLoading(true);
const key = `lume_topic_${search.label}`; const key = `lume_topic_${search.label}`;
const createTopic = await ark.set_nstore(key, JSON.stringify(topics)); const createTopic = await NostrQuery.setNstore(
key,
JSON.stringify(topics),
);
if (createTopic) { if (createTopic) {
return navigate({ to: search.redirect, search: { ...search } }); return navigate({ to: search.redirect, search: { ...search } });
} }
} catch (e) { } catch (e) {
setIsLoading(false); setIsLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Create Topic",
kind: "error",
});
} }
}; };
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center w-full h-full gap-4">
<div className="text-center flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center text-center">
<h1 className="text-2xl font-serif font-medium"> <h1 className="font-serif text-2xl font-medium">
What are your interests? What are your interests?
</h1> </h1>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <p className="leading-tight text-neutral-700 dark:text-neutral-300">
Add some topics you want to focus on. Add some topics you want to focus on.
</p> </p>
</div> </div>
<div className="w-4/5 max-w-full flex flex-col gap-3"> <div className="flex flex-col w-4/5 max-w-full gap-3">
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-3"> <div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
<span className="text-sm font-medium">Added: {topics.length}</span> <span className="text-sm font-medium">Added: {topics.length}</span>
</div> </div>
<div className="w-full flex flex-col items-center gap-3"> <div className="flex flex-col items-center w-full gap-3">
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl"> <div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{TOPICS.map((topic) => ( {TOPICS.map((topic) => (
<button <button
key={topic.title} key={topic.title}
type="button"
onClick={() => toggleTopic(topic)} onClick={() => toggleTopic(topic)}
className="h-11 px-3 flex items-center justify-between bg-white dark:bg-black/20 backdrop-blur-lg border border-transparent hover:border-blue-500 rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50" className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 backdrop-blur-lg hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
> >
<div className="inline-flex items-center gap-1"> <div className="inline-flex items-center gap-1">
<div>{topic.icon}</div> <div>{topic.icon}</div>
<div className="text-sm font-medium"> <div className="text-sm font-medium">
<span>{topic.title}</span> <span>{topic.title}</span>
<span className="ml-1 italic text-neutral-400 dark:text-neutral-600 font-normal"> <span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
{topic.content.length} hashtags {topic.content.length} hashtags
</span> </span>
</div> </div>
@@ -93,7 +104,7 @@ function Screen() {
type="button" type="button"
onClick={() => submit()} onClick={() => submit()}
disabled={isLoading || topics.length < 1} disabled={isLoading || topics.length < 1}
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{isLoading ? <Spinner /> : "Confirm"} {isLoading ? <Spinner /> : "Confirm"}
</button> </button>
@@ -1,32 +1,30 @@
import { AddMediaIcon } from "@lume/icons"; import { AddMediaIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn, insertImage, isImagePath } from "@lume/utils"; import { 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 type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrent } from "@tauri-apps/api/window";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSlateStatic } from "slate-react"; import { useSlateStatic } from "slate-react";
import { toast } from "sonner";
export function MediaButton({ className }: { className?: string }) { export function MediaButton() {
const editor = useSlateStatic(); const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => { const upload = async () => {
try { try {
// start loading // start loading
setLoading(true); setLoading(true);
const image = await ark.upload(); const image = await NostrQuery.upload();
insertImage(editor, image); insertImage(editor, image);
// reset loading // reset loading
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(`Upload failed, error: ${e}`); await message(String(e), { title: "Upload", kind: "error" });
} }
}; };
@@ -44,7 +42,7 @@ export function MediaButton({ className }: { className?: string }) {
// upload all images // upload all images
for (const item of items) { for (const item of items) {
if (isImagePath(item)) { if (isImagePath(item)) {
const image = await ark.upload(item); const image = await NostrQuery.upload(item);
insertImage(editor, image); insertImage(editor, image);
} }
} }
@@ -62,29 +60,18 @@ export function MediaButton({ className }: { className?: string }) {
}, []); }, []);
return ( return (
<Tooltip.Provider> <button
<Tooltip.Root delayDuration={150}> type="button"
<Tooltip.Trigger asChild> onClick={() => upload()}
<button disabled={loading}
type="button" className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
onClick={() => uploadToNostrBuild()} >
disabled={loading} {loading ? (
className={cn("inline-flex items-center justify-center", className)} <Spinner className="size-4" />
> ) : (
{loading ? ( <AddMediaIcon className="size-4" />
<Spinner className="size-4" /> )}
) : ( Add media
<AddMediaIcon className="size-4" /> </button>
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Upload media
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
); );
} }
@@ -1,83 +0,0 @@
import { MentionIcon } from "@lume/icons";
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";
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 contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact);
} catch (e) {
toast.error(String(e));
}
};
useEffect(() => {
async function getContacts() {
const data = await ark.get_contact_list();
setContacts(data);
}
getContacts();
}, []);
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center",
className,
)}
>
<MentionIcon className="size-4" />
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mention
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</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>
))}
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
@@ -1,40 +0,0 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react";
export function NsfwToggle({
nsfw,
setNsfw,
className,
}: {
nsfw: boolean;
setNsfw: Dispatch<SetStateAction<boolean>>;
className?: string;
}) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setNsfw((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
nsfw ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mark as sensitive content
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
@@ -1,40 +1,21 @@
import { NsfwIcon } from "@lume/icons"; import { PowIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
export function PowToggle({ export function PowButton({
pow, setDifficulty,
setPow,
className,
}: { }: {
pow: boolean; setDifficulty: Dispatch<SetStateAction<{ enable: boolean; num: number }>>;
setPow: Dispatch<SetStateAction<boolean>>;
className?: string;
}) { }) {
return ( return (
<Tooltip.Provider> <button
<Tooltip.Root delayDuration={150}> type="button"
<Tooltip.Trigger asChild> onClick={() =>
<button setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
type="button" }
onClick={() => setPow((prev) => !prev)} className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
className={cn( >
"inline-flex items-center justify-center", <PowIcon className="size-4" />
className, PoW
pow ? "bg-blue-500 text-white" : "", </button>
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Proof of Work
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
); );
} }
@@ -0,0 +1,19 @@
import { NsfwIcon } from "@lume/icons";
import type { Dispatch, SetStateAction } from "react";
export function WarningButton({
setWarning,
}: {
setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
}) {
return (
<button
type="button"
onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
className="inline-flex items-center h-8 gap-2 px-2.5 text-sm rounded-lg text-black/70 dark:text-white/70 w-max hover:bg-black/10 dark:hover:bg-white/10"
>
<NsfwIcon className="size-4" />
Mark as sensitive
</button>
);
}
+176 -119
View File
@@ -1,16 +1,8 @@
import { ComposeFilledIcon, TrashIcon } from "@lume/icons"; import { ComposeFilledIcon } from "@lume/icons";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { import { cn, insertImage, insertNostrEvent, isImageUrl } from "@lume/utils";
cn,
insertImage,
insertNostrEvent,
isImageUrl,
sendNativeNotification,
} from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools"; import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type Descendant, Node, Transforms, createEditor } from "slate"; import { type Descendant, Node, Transforms, createEditor } from "slate";
import { import {
Editable, Editable,
@@ -22,60 +14,70 @@ import {
withReact, withReact,
} from "slate-react"; } from "slate-react";
import { MediaButton } from "./-components/media"; import { MediaButton } from "./-components/media";
import { NsfwToggle } from "./-components/nsfw"; import { LumeEvent, useEvent } from "@lume/system";
import { MentionButton } from "./-components/mention"; import { WarningButton } from "./-components/warning";
import { MentionNote } from "@/components/note/mentions/note"; import { MentionNote } from "@/components/note/mentions/note";
import { toast } from "sonner"; import { PowButton } from "./-components/pow";
import { User } from "@/components/user";
import { Note } from "@/components/note";
import { nip19 } from "nostr-tools";
type EditorSearch = { type EditorSearch = {
reply_to: string; reply_to: string;
quote: boolean; quote: string;
};
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
}; };
export const Route = createFileRoute("/editor/")({ export const Route = createFileRoute("/editor/")({
validateSearch: (search: Record<string, string>): EditorSearch => { validateSearch: (search: Record<string, string>): EditorSearch => {
return { return {
reply_to: search.reply_to, reply_to: search.reply_to,
quote: search.quote === "true" || false, quote: search.quote,
}; };
}, },
beforeLoad: async ({ search }) => { beforeLoad: ({ search }) => {
return { let initialValue: EditorElement[];
initialValue: search.quote
? [ if (search?.quote?.length) {
{ const eventId = nip19.noteEncode(search.quote);
type: "paragraph", initialValue = [
children: [{ text: "" }], {
}, type: "paragraph",
{ children: [{ text: "" }],
type: "event", },
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`, {
children: [{ text: "" }], type: "event",
}, eventId: `nostr:${eventId}`,
{ children: [{ text: "" }],
type: "paragraph", },
children: [{ text: "" }], ];
}, } else {
] initialValue = [
: [ {
{ type: "paragraph",
type: "paragraph", children: [{ text: "" }],
children: [{ text: "" }], },
}, ];
], }
};
return { initialValue };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { reply_to, quote } = Route.useSearch(); const { reply_to } = Route.useSearch();
const { ark, initialValue } = Route.useRouteContext(); const { initialValue } = Route.useRouteContext();
const [t] = useTranslation(); const [editorValue, setEditorValue] = useState<EditorElement[]>(null);
const [editorValue, setEditorValue] = useState(initialValue);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nsfw, setNsfw] = useState(false); const [warning, setWarning] = useState({ enable: false, reason: "" });
const [difficulty, setDifficulty] = useState({ enable: false, num: 21 });
const [editor] = useState(() => const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))), withMentions(withNostrEvent(withImages(withReact(createEditor())))),
); );
@@ -116,60 +118,42 @@ function Screen() {
setLoading(true); setLoading(true);
const content = serialize(editor.children); const content = serialize(editor.children);
const eventId = await ark.publish(content, reply_to, quote); const eventId = await LumeEvent.publish(
content,
warning.enable && warning.reason.length ? warning.reason : null,
difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
reply_to,
);
if (eventId) { if (eventId) {
await sendNativeNotification( // stop loading
"Your note has been published successfully.", setLoading(false);
"Lume", // reset form
); reset();
} }
// stop loading
setLoading(false);
// reset form
reset();
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
await sendNativeNotification(String(e));
} }
}; };
useEffect(() => {
setEditorValue(initialValue);
}, [initialValue]);
if (!editorValue) return null;
return ( return (
<div className="w-full h-full"> <div className="flex flex-col w-full h-full">
<Slate editor={editor} initialValue={editorValue}> <Slate editor={editor} initialValue={editorValue}>
<div <div data-tauri-drag-region className="h-9 shrink-0" />
data-tauri-drag-region <div className="flex flex-col flex-1 overflow-y-auto">
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" {reply_to?.length ? (
> <div className="flex items-center gap-3 px-2.5 pb-3 border-b border-black/5 dark:border-white/5">
<NsfwToggle <div className="text-sm font-semibold shrink-0">Reply to:</div>
nsfw={nsfw} <ChildNote id={reply_to} />
setNsfw={setNsfw}
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" />
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<button
type="button"
onClick={() => publish()}
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"
>
{loading ? (
<Spinner className="size-4" />
) : (
<ComposeFilledIcon className="size-4" />
)}
{t("global.post")}
</button>
</div>
<div className="flex h-full w-full flex-1 flex-col">
{reply_to && !quote ? (
<div className="px-4 py-2">
<MentionNote eventId={reply_to} />
</div> </div>
) : null} ) : null}
<div className="overflow-y-auto p-4"> <div className="px-4 py-4 overflow-y-auto">
<Editable <Editable
key={JSON.stringify(editorValue)} key={JSON.stringify(editorValue)}
autoFocus={true} autoFocus={true}
@@ -178,17 +162,103 @@ function Screen() {
spellCheck={false} spellCheck={false}
renderElement={(props) => <Element {...props} />} renderElement={(props) => <Element {...props} />}
placeholder={ placeholder={
reply_to ? "Type your reply..." : t("editor.placeholder") reply_to ? "Type your reply..." : "What're you up to?"
} }
className="focus:outline-none" className="focus:outline-none"
/> />
</div> </div>
</div> </div>
{warning.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Reason:
</span>
<input
type="text"
placeholder="NSFW..."
value={warning.reason}
onChange={(e) =>
setWarning((prev) => ({ ...prev, reason: e.target.value }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
{difficulty.enable ? (
<div className="flex items-center w-full px-4 border-t h-11 shrink-0 border-black/5 dark:border-white/5">
<span className="text-sm shrink-0 text-black/50 dark:text-white/50">
Difficulty:
</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]"
onKeyDown={(event) => {
if (!/[0-9]/.test(event.key)) {
event.preventDefault();
}
}}
placeholder="21"
defaultValue={difficulty.num}
onChange={(e) =>
setWarning((prev) => ({ ...prev, num: Number(e.target.value) }))
}
className="flex-1 text-sm bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-black/50 dark:placeholder:text-white/50"
/>
</div>
) : null}
<div
data-tauri-drag-region
className="flex items-center w-full h-16 gap-4 px-4 border-t divide-x divide-black/5 dark:divide-white/5 shrink-0 border-black/5 dark:border-white/5"
>
<button
type="button"
onClick={() => publish()}
className="inline-flex items-center justify-center h-8 gap-1 px-2.5 text-sm font-medium rounded-lg bg-black/10 w-max hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
{loading ? (
<Spinner className="size-4" />
) : (
<ComposeFilledIcon className="size-4" />
)}
Publish
</button>
<div className="inline-flex items-center flex-1 gap-2 pl-4">
<MediaButton />
<WarningButton setWarning={setWarning} />
<PowButton setDifficulty={setDifficulty} />
</div>
</div>
</Slate> </Slate>
</div> </div>
); );
} }
function ChildNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
return <Spinner className="size-5" />;
}
if (isError || !data) {
return <div>Event not found with your current relay set.</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="rounded-full size-8 shrink-0" />
</User.Root>
</User.Provider>
<div className="content-break line-clamp-1">{data.content}</div>
</Note.Root>
</Note.Provider>
);
}
const withNostrEvent = (editor: ReactEditor) => { const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor; const { insertData, isVoid } = editor;
@@ -200,7 +270,7 @@ const withNostrEvent = (editor: ReactEditor) => {
editor.insertData = (data) => { editor.insertData = (data) => {
const text = data.getData("text/plain"); const text = data.getData("text/plain");
if (text.startsWith("nevent1") || text.startsWith("note1")) { if (text.startsWith("nevent") || text.startsWith("note")) {
insertNostrEvent(editor, text); insertNostrEvent(editor, text);
} else { } else {
insertData(data); insertData(data);
@@ -252,35 +322,25 @@ const withImages = (editor: ReactEditor) => {
return editor; return editor;
}; };
const Image = ({ attributes, children, element }) => { const Image = ({ attributes, element, children }) => {
const editor = useSlateStatic(); const editor = useSlateStatic();
const path = ReactEditor.findPath(editor as ReactEditor, element);
const selected = useSelected(); const selected = useSelected();
const focused = useFocused(); const focused = useFocused();
const path = ReactEditor.findPath(editor as ReactEditor, element);
return ( return (
<div {...attributes}> <div {...attributes}>
{children} {children}
<div contentEditable={false} className="relative my-2"> <img
<img src={element.url}
src={element.url} alt={element.url}
alt={element.url} className={cn(
className={cn( "my-2 h-auto w-1/2 rounded-lg object-cover ring-2 outline outline-1 -outline-offset-1 outline-black/15",
"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",
selected && focused ? "ring-blue-500" : "ring-transparent", )}
)} onClick={() => Transforms.removeNodes(editor, { at: path })}
contentEditable={false} onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
/> />
<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>
</div> </div>
); );
}; };
@@ -295,7 +355,7 @@ const Mention = ({ attributes, element }) => {
type="button" type="button"
contentEditable={false} contentEditable={false}
onClick={() => Transforms.removeNodes(editor, { at: path })} onClick={() => Transforms.removeNodes(editor, { at: path })}
className="inline-block align-baseline text-blue-500 hover:text-blue-600" className="inline-block text-blue-500 align-baseline hover:text-blue-600"
>{`@${element.name}`}</span> >{`@${element.name}`}</span>
); );
}; };
@@ -307,16 +367,13 @@ const Event = ({ attributes, element, children }) => {
return ( return (
<div {...attributes}> <div {...attributes}>
{children} {children}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
<div <div
contentEditable={false} contentEditable={false}
className="relative my-2 user-select-none"
onClick={() => Transforms.removeNodes(editor, { at: path })} onClick={() => Transforms.removeNodes(editor, { at: path })}
className="user-select-none relative my-2" onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
> >
<MentionNote <MentionNote eventId={element.eventId} openable={false} />
eventId={element.eventId.replace("nostr:", "")}
openable={false}
/>
</div> </div>
</div> </div>
); );
+58 -48
View File
@@ -1,72 +1,82 @@
import { useEvent } from "@lume/ark";
import type { Event } from "@lume/types";
import { Box, Container, Spinner } from "@lume/ui";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { type LumeEvent, NostrQuery } from "@lume/system";
import { Box, Container, Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { WindowVirtualizer } from "virtua"; import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList"; import { Reply } from "./-components/reply";
export const Route = createFileRoute("/events/$eventId")({ export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const settings = await NostrQuery.getUserSettings();
const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
loader: async ({ params }) => {
const event = await NostrQuery.getEvent(params.eventId);
return event;
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { eventId } = Route.useParams(); const event = Route.useLoaderData();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) { const [reload, setReload] = useState(false);
return ( const [replies, setReplies] = useState<LumeEvent[]>(null);
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
);
}
if (isError) { useEffect(() => {
<div className="flex h-full w-full items-center justify-center"> let mounted = true;
<p>Not found.</p>
</div>; if (event) {
} event.getAllReplies().then((data) => {
if (mounted) setReplies(data);
});
}
return () => {
mounted = false;
};
}, [event]);
return ( return (
<Container withDrag> <Container withDrag>
<Box className="scrollbar-none"> <Box className="scrollbar-none">
<WindowVirtualizer> <WindowVirtualizer>
<MainNote data={data} /> <Note.Provider event={event}>
{data ? ( <Note.Root>
<ReplyList eventId={eventId} /> <div className="flex items-center justify-between px-3 h-14">
) : ( <Note.User />
<div className="flex h-full w-full items-center justify-center"> <Note.Menu />
<Spinner className="size-5" /> </div>
<Note.ContentLarge className="px-3" />
<div className="flex items-center justify-end gap-2 px-3 mt-4 h-11">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
<div className="flex flex-col">
<div className="flex items-center px-3 text-sm font-semibold border-t h-11 text-neutral-700 dark:text-neutral-300 border-neutral-100 dark:border-neutral-900">
Replies ({replies?.length ?? 0})
</div> </div>
)} {!replies ? (
<Spinner />
) : !replies.length ? (
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply!
</p>
</div>
</div>
) : (
replies.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
</WindowVirtualizer> </WindowVirtualizer>
</Box> </Box>
</Container> </Container>
); );
} }
function MainNote({ data }: { data: Event }) {
return (
<Note.Provider event={data}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-4 h-11 gap-2 flex items-center justify-end px-3">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
);
}
@@ -1,18 +1,18 @@
import type { EventWithReplies } from "@lume/types"; import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { SubReply } from "./subReply"; import { SubReply } from "./subReply";
import { Note } from "@/components/note";
export function Reply({ event }: { event: EventWithReplies }) { export function Reply({ event }: { event: LumeEvent }) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900"> <Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
<div className="px-3 h-14 flex items-center justify-between"> <div className="flex items-center justify-between px-3 h-14">
<Note.User /> <Note.User />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.ContentLarge className="px-3" /> <Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3 h-14"> <div className="flex items-center gap-4 px-3 mt-3 h-14">
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />
<Note.Zap /> <Note.Zap />
@@ -1,10 +1,10 @@
import type { EventWithReplies } from "@lume/types"; import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Reply } from "./reply"; import { Reply } from "./reply";
import { LumeEvent } from "@lume/system";
export function ReplyList({ export function ReplyList({
eventId, eventId,
@@ -13,13 +13,12 @@ export function ReplyList({
eventId: string; eventId: string;
className?: string; className?: string;
}) { }) {
const { ark } = useRouteContext({ strict: false });
const [t] = useTranslation(); const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null); const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => { useEffect(() => {
async function getReplies() { async function getReplies() {
const events = await ark.get_event_thread(eventId); const events = await LumeEvent.getReplies(eventId);
setData(events); setData(events);
} }
getReplies(); getReplies();
@@ -1,7 +1,12 @@
import type { Event } from "@lume/types"; import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
export function SubReply({ event }: { event: Event; rootEventId?: string }) { export function SubReply({
event,
}: {
event: NostrEvent;
rootEventId?: string;
}) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root> <Note.Root>
+87 -102
View File
@@ -2,11 +2,14 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote"; import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/global")({ export const Route = createFileRoute("/global")({
@@ -17,10 +20,8 @@ export const Route = createFileRoute("/global")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const settings = await NostrQuery.getUserSettings();
const settings = await ark.get_settings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
@@ -28,7 +29,6 @@ export const Route = createFileRoute("/global")({
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -40,111 +40,96 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_global_events(20, pageParam); const events = await NostrQuery.getGlobalEvents(pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const ref = useRef<HTMLDivElement>(null);
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) { const renderItem = useCallback(
return <Conversation key={event.id} event={event} className="mb-3" />; (event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
} },
}; [data],
);
return ( return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="w-full h-11 flex items-center justify-center"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
</div> <Virtualizer scrollRef={ref}>
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
) : null} <div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex h-16 w-full items-center justify-center gap-2"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">
<span className="text-sm font-medium">Loading...</span> Fetching new notes...
</div> </span>
) : !data.length ? ( </div>
<Empty /> </div>
) : ( ) : null}
<Virtualizer overscan={3}> {isLoading ? (
{data.map((item) => renderItem(item))} <div className="flex items-center justify-center w-full h-16 gap-2">
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
</div> {data?.length && hasNextPage ? (
); <div>
} <button
type="button"
function Empty() { onClick={() => fetchNextPage()}
return ( disabled={isFetchingNextPage || isLoading}
<div className="flex flex-col py-10 gap-10"> className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
<div className="text-center flex flex-col items-center justify-center"> >
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8"> {isFetchingNextPage ? (
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <Spinner className="size-5" />
</div> ) : (
<p className="text-lg font-medium">Your newsfeed is empty</p> <>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <ArrowRightCircleIcon className="size-5" />
Here are few suggestions to get started. Load more
</p> </>
</div> )}
<div className="flex flex-col px-3 gap-2"> </button>
<Link </div>
to="/trending/notes" ) : null}
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" </Virtualizer>
> </ScrollArea.Viewport>
<ArrowRightIcon className="size-5" /> <ScrollArea.Scrollbar
Show trending notes className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
</Link> orientation="vertical"
<Link >
to="/trending/users" <ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" </ScrollArea.Scrollbar>
> <ScrollArea.Corner className="bg-transparent" />
<ArrowRightIcon className="size-5" /> </ScrollArea.Root>
Discover trending users
</Link>
</div>
</div>
); );
} }
+91 -108
View File
@@ -2,11 +2,14 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote"; import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/group")({ export const Route = createFileRoute("/group")({
@@ -17,11 +20,10 @@ export const Route = createFileRoute("/group")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search }) => {
const ark = context.ark; const key = `lume:group:${search.label}`;
const key = `lume_group_${search.label}`; const groups: string[] = await NostrQuery.getNstore(key);
const groups = (await ark.get_nstore(key)) as string[]; const settings = await NostrQuery.getUserSettings();
const settings = await ark.get_settings();
if (!groups?.length) { if (!groups?.length) {
throw redirect({ throw redirect({
@@ -33,17 +35,14 @@ export const Route = createFileRoute("/group")({
}); });
} }
return { return { groups, settings };
groups,
settings,
};
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark, groups } = Route.useRouteContext(); const { groups } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -55,112 +54,96 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_local_events(groups, 20, pageParam); const events = await NostrQuery.getGroupEvents(groups, pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => select: (data) => data?.pages.flat(),
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const ref = useRef<HTMLDivElement>(null);
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) { const renderItem = useCallback(
return <Conversation key={event.id} event={event} className="mb-3" />; (event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
} },
}; [data],
);
return ( return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
</div> <Virtualizer scrollRef={ref}>
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
) : null} <div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex h-16 w-full items-center justify-center gap-2"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">
<span className="text-sm font-medium">Loading...</span> Fetching new notes...
</div> </span>
) : !data.length ? ( </div>
<Empty /> </div>
) : ( ) : null}
<Virtualizer overscan={3}> {isLoading ? (
{data.map((item) => renderItem(item))} <div className="flex items-center justify-center w-full h-16 gap-2">
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-neutral-100 px-3 font-medium hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
</div> {data?.length && hasNextPage ? (
); <div>
} <button
type="button"
function Empty() { onClick={() => fetchNextPage()}
return ( disabled={isFetchingNextPage || isLoading}
<div className="flex flex-col py-10 gap-10"> className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
<div className="text-center flex flex-col items-center justify-center"> >
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full mb-8"> {isFetchingNextPage ? (
<div className="w-12 h-16 bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900 rounded-t-lg" /> <Spinner className="size-5" />
</div> ) : (
<p className="text-lg font-medium">Your newsfeed is empty</p> <>
<p className="leading-tight text-neutral-700 dark:text-neutral-300"> <ArrowRightCircleIcon className="size-5" />
Here are few suggestions to get started. Load more
</p> </>
</div> )}
<div className="flex flex-col px-3 gap-2"> </button>
<Link </div>
to="/trending/notes" ) : null}
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" </Virtualizer>
> </ScrollArea.Viewport>
<ArrowRightIcon className="size-5" /> <ScrollArea.Scrollbar
Show trending notes className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
</Link> orientation="vertical"
<Link >
to="/trending/users" <ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
className="h-11 w-full flex items-center hover:bg-neutral-200 text-sm font-medium dark:hover:bg-neutral-800 gap-2 bg-neutral-100 rounded-lg dark:bg-neutral-900 px-3" </ScrollArea.Scrollbar>
> <ScrollArea.Corner className="bg-transparent" />
<ArrowRightIcon className="size-5" /> </ScrollArea.Root>
Discover trending users
</Link>
</div>
</div>
); );
} }
+85 -72
View File
@@ -1,31 +1,30 @@
import { PlusIcon } from "@lume/icons";
import { Spinner } from "@lume/ui";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { checkForAppUpdates } from "@lume/utils"; import { PlusIcon, RelayIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { checkForAppUpdates, displayNpub } from "@lume/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
// check for app updates // Check for app updates
// TODO: move this function to rust
await checkForAppUpdates(true); await checkForAppUpdates(true);
const ark = context.ark; // Get all accounts
const accounts = await ark.get_accounts(); // TODO: use emit & listen
const accounts = await NostrAccount.getAccounts();
if (!accounts.length) { if (accounts.length < 1) {
throw redirect({ throw redirect({
to: "/landing/", to: "/landing",
replace: true, replace: true,
}); });
} }
// Run notification service
await invoke("run_notification", { accounts });
return { accounts }; return { accounts };
}, },
component: Screen, component: Screen,
@@ -33,16 +32,17 @@ export const Route = createFileRoute("/")({
function Screen() { function Screen() {
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const { ark, accounts } = Route.useRouteContext(); const context = Route.useRouteContext();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState({ npub: "", status: false });
const select = async (npub: string) => { const select = async (npub: string) => {
try { try {
setLoading(true); setLoading({ npub, status: true });
const loadAccount = await ark.load_account(npub); const status = await NostrAccount.loadAccount(npub);
if (loadAccount) {
if (status) {
return navigate({ return navigate({
to: "/$account/home", to: "/$account/home",
params: { account: npub }, params: { account: npub },
@@ -50,8 +50,11 @@ function Screen() {
}); });
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading({ npub: "", status: false });
toast.error(String(e)); await message(String(e), {
title: "Account",
kind: "error",
});
} }
}; };
@@ -62,62 +65,72 @@ function Screen() {
}); });
return ( return (
<div className="relative flex h-full w-full items-center justify-center"> <div
<div className="relative z-20 flex flex-col items-center gap-16"> data-tauri-drag-region
<div className="text-center text-white"> className="flex flex-col items-center justify-between w-full h-full"
<h2 className="mb-1 text-2xl">{currentDate}</h2> >
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
<div className="text-center">
<h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300">
{currentDate}
</h2>
<h2 className="text-2xl font-semibold">Welcome back!</h2> <h2 className="text-2xl font-semibold">Welcome back!</h2>
</div> </div>
<div className="flex flex-wrap px-3 items-center justify-center gap-6"> </div>
{loading ? ( <div className="flex flex-col items-center flex-1 w-full gap-3">
<div className="inline-flex size-6 items-center justify-center"> <div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/10 dark:ring-1 ring-white/15">
<Spinner className="size-6 text-white" /> {context.accounts.map((account) => (
</div> <div
) : ( key={account}
<> onClick={() => select(account)}
{accounts.map((account) => ( onKeyDown={() => select(account)}
<button className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
type="button" >
key={account} <User.Provider pubkey={account}>
onClick={() => select(account)} <User.Root className="flex items-center gap-2.5 p-3">
> <User.Avatar className="object-cover rounded-full size-10 shrink-0" />
<User.Provider pubkey={account}> <div className="inline-flex flex-col items-start">
<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.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<User.Avatar className="size-20 rounded-full object-cover" /> <span className="text-sm text-neutral-700 dark:text-neutral-300">
<User.Name className="max-w-[5rem] truncate text-lg font-medium leading-tight text-white" /> {displayNpub(account, 16)}
</User.Root> </span>
</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">
<PlusIcon className="size-8" />
</div> </div>
<p className="text-lg font-medium leading-tight">Add</p> </User.Root>
</div> </User.Provider>
</Link> <div className="inline-flex items-center justify-center size-10">
</> {loading.npub === account ? (
)} loading.status ? (
<Spinner />
) : null
) : null}
</div>
</div>
))}
<Link
to="/landing"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
<PlusIcon className="size-5" />
</div>
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
Add account
</span>
</div>
</Link>
</div>
<div className="w-full max-w-sm mx-auto">
<Link
to="/bootstrap-relays"
className="inline-flex items-center justify-center w-full h-8 gap-2 px-2 text-xs font-medium rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-700 dark:text-white/40"
>
<RelayIcon className="size-4" />
Custom Bootstrap Relays
</Link>
</div> </div>
</div> </div>
<div className="absolute z-10 h-full w-full bg-white/10 backdrop-blur-lg dark:bg-black/10" /> <div className="flex-1" />
<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> </div>
); );
} }
+62
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 items-center justify-center w-screen h-screen"
>
<div className="w-full max-w-xs mx-auto lg:max-w-md">
<div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
<div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5">
<Link
to="/auth/create-profile"
className="flex items-center justify-center w-full gap-2 px-2 rounded-lg h-14 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="inline-flex items-center justify-center rounded-full size-9 shrink-0">
<img
src="/icon.jpeg"
alt="App Icon"
className="object-cover rounded-full size-9"
/>
</div>
<div className="inline-flex flex-col flex-1">
<span className="font-semibold leading-tight">
Create new account
</span>
<span className="text-sm leading-tight text-neutral-500">
Use everywhere
</span>
</div>
</Link>
</div>
<div className="flex flex-col gap-1 pb-2.5">
<Link
to="/auth/import"
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="inline-flex items-center justify-center size-9">
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Login with Private Key
</Link>
<Link
to="/auth/remote"
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
>
<div className="inline-flex items-center justify-center size-9">
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div>
Nostr Connect
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -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>
);
}
+90 -75
View File
@@ -2,12 +2,14 @@ import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote"; import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { ArrowRightCircleIcon, ArrowRightIcon } from "@lume/icons"; import { ArrowRightCircleIcon } from "@lume/icons";
import { type ColumnRouteSearch, type Event, Kind } from "@lume/types"; import { type LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, Kind } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { Link, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/newsfeed")({ export const Route = createFileRoute("/newsfeed")({
@@ -18,12 +20,11 @@ export const Route = createFileRoute("/newsfeed")({
name: search.name, name: search.name,
}; };
}, },
beforeLoad: async ({ search, context }) => { beforeLoad: async ({ search }) => {
const ark = context.ark; const isContactListEmpty = await NostrAccount.isContactListEmpty();
const settings = await ark.get_settings(); const settings = await NostrQuery.getUserSettings();
const contacts = await ark.get_contact_list();
if (!contacts.length) { if (isContactListEmpty) {
throw redirect({ throw redirect({
to: "/create-newsfeed/users", to: "/create-newsfeed/users",
search: { search: {
@@ -33,14 +34,13 @@ export const Route = createFileRoute("/newsfeed")({
}); });
} }
return { settings, contacts }; return { settings };
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { ark, contacts } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -52,81 +52,96 @@ export function Screen() {
queryKey: [label, account], queryKey: [label, account],
initialPageParam: 0, initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_local_events(contacts, 20, pageParam); const events = await NostrQuery.getLocalEvents(pageParam);
return events; return events;
}, },
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1, getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page), select: (data) => data?.pages.flat(),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const renderItem = (event: Event) => { const ref = useRef<HTMLDivElement>(null);
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default: {
const isConversation =
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
.length > 0;
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
if (isConversation) { const renderItem = useCallback(
return <Conversation key={event.id} event={event} className="mb-3" />; (event: LumeEvent) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} className="mb-3" />;
default: {
if (event.isConversation) {
return (
<Conversation key={event.id} className="mb-3" event={event} />
);
}
if (event.isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />;
}
return <TextNote key={event.id} event={event} className="mb-3" />;
} }
} },
}; [data],
);
return ( return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none"> <ScrollArea.Root
{isFetching && !isLoading && !isFetchingNextPage ? ( type={"scroll"}
<div className="mb-3 w-full h-11 flex items-center justify-center bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> scrollHideDelay={300}
<div className="flex items-center justify-center gap-2"> className="overflow-hidden size-full"
<Spinner className="size-5" /> >
<span className="text-sm font-medium">Fetching new notes...</span> <ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
</div> <Virtualizer scrollRef={ref}>
</div> {isFetching && !isLoading && !isFetchingNextPage ? (
) : null} <div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
{isLoading ? ( <div className="flex items-center justify-center gap-2">
<div className="flex h-16 w-full items-center justify-center gap-2"> <Spinner className="size-5" />
<Spinner className="size-5" /> <span className="text-sm font-medium">
<span className="text-sm font-medium">Loading...</span> Fetching new notes...
</div> </span>
) : !data.length ? ( </div>
<div className="flex items-center justify-center"> </div>
Yo. You're catching up on all the things happening around you. ) : null}
</div> {isLoading ? (
) : ( <div className="flex items-center justify-center w-full h-16 gap-2">
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
{data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-black/5 px-3 font-medium hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
) : ( <span className="text-sm font-medium">Loading...</span>
<> </div>
<ArrowRightCircleIcon className="size-5" /> ) : !data.length ? (
Load more <div className="flex items-center justify-center">
</> Yo. You're catching up on all the things happening around you.
)} </div>
</button> ) : (
</div> data.map((item) => renderItem(item))
) : null} )}
</div> {data?.length && hasNextPage ? (
<div>
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading}
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
>
{isFetchingNextPage ? (
<Spinner className="size-5" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
</div>
) : null}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
); );
} }
+30 -40
View File
@@ -1,4 +1,5 @@
import { ZapIcon } from "@lume/icons"; import { ZapIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Container } from "@lume/ui"; import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
@@ -8,54 +9,43 @@ export const Route = createLazyFileRoute("/nwc")({
}); });
function Screen() { function Screen() {
const { ark } = Route.useRouteContext();
const [uri, setUri] = useState(""); const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false); const [isDone, setIsDone] = useState(false);
const save = async () => { const save = async () => {
const nwc = await ark.set_nwc(uri); const nwc = await NostrAccount.setWallet(uri);
setIsDone(nwc); setIsDone(nwc);
}; };
return ( return (
<Container withDrag withNavigate={false}> <Container withDrag>
<div className="h-full w-full flex-1 px-5"> <div className="flex-1 w-full h-full px-5">
{!isDone ? ( <div className="flex flex-col gap-2">
<> <div>
<div className="flex flex-col gap-2"> <h3 className="text-2xl font-light">
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md"> Connect <span className="font-semibold">bitcoin wallet</span> to
<ZapIcon className="size-5" /> start zapping to your favorite content and creator.
</div> </h3>
<div> </div>
<h3 className="text-2xl font-light"> </div>
Connect <span className="font-semibold">bitcoin wallet</span>{" "} <div className="flex flex-col gap-2 mt-10">
to start zapping to your favorite content and creator. <div className="flex flex-col gap-1.5">
</h3> <label>Paste a Nostr Wallet Connect connection string</label>
</div> <textarea
</div> value={uri}
<div className="mt-10 flex flex-col gap-2"> onChange={(e) => setUri(e.target.value)}
<div className="flex flex-col gap-1.5"> placeholder="nostrconnect://"
<label>Paste a Nostr Wallet Connect connection string</label> className="w-full h-24 px-3 bg-transparent rounded-lg border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
<textarea />
value={uri} </div>
onChange={(e) => setUri(e.target.value)} <button
placeholder="nostrconnect://" type="button"
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" onClick={save}
/> className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
</div> >
<button Save & Connect
type="button" </button>
onClick={save} </div>
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
Save & Connect
</button>
</div>
</>
) : (
<div>Done</div>
)}
</div> </div>
</Container> </Container>
); );
+8
View File
@@ -34,6 +34,8 @@ function Screen() {
className="h-auto w-full aspect-square rounded-lg shadow-md transform" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_1.jpeg"
> >
<source <source
src="https://video.nostr.build/692f71e2be47ecfc29edcbdaa198cc5979bfb9c900f05d78682895dd546d8d4f.mp4" 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" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_2.jpeg"
> >
<source <source
src="https://video.nostr.build/d33962520506d86acfb4b55a7b265821e10ae637f5ec830a173b7e6092b16ec8.mp4" 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" className="h-auto w-full aspect-square rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_3.jpeg"
> >
<source <source
src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4" src="https://video.nostr.build/927abbfde2097e470ac751181b1db456b7e4b9149550408efff1a966a7ffb9a8.mp4"
@@ -97,6 +103,8 @@ function Screen() {
className="h-auto w-full rounded-lg shadow-md transform" className="h-auto w-full rounded-lg shadow-md transform"
controls controls
muted muted
preload="none"
poster="/poster_4.jpeg"
> >
<source <source
src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4" src="https://video.nostr.build/513de4824b6abaf7e9698c1dad2f68096574356848c0c200bc8cb8074df29410.mp4"
-48
View File
@@ -1,48 +0,0 @@
import { PlusIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types";
import { createLazyRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
export const Route = createLazyRoute("/open")({
component: Screen,
});
function Screen() {
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="group absolute left-0 top-0 z-10 h-full w-12">
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
>
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
</button>
</div>
<button
type="button"
onClick={() =>
install({
label: "store",
name: "Store",
content: "/store/official",
})
}
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<PlusIcon className="size-8" />
</button>
</div>
);
}
+406
View File
@@ -0,0 +1,406 @@
import { Note } from "@/components/note";
import { User } from "@/components/user";
import {
HorizontalDotsIcon,
InfoIcon,
RepostIcon,
SearchIcon,
} from "@lume/icons";
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
import { Kind } from "@lume/types";
import {
checkForAppUpdates,
decodeZapInvoice,
formatCreatedAt,
} from "@lume/utils";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import * as Tabs from "@radix-ui/react-tabs";
import { createFileRoute } from "@tanstack/react-router";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrent } from "@tauri-apps/api/window";
import { exit } from "@tauri-apps/plugin-process";
import { open } from "@tauri-apps/plugin-shell";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Virtualizer } from "virtua";
interface EmitAccount {
account: string;
}
export const Route = createFileRoute("/panel")({
component: Screen,
});
function Screen() {
const [account, setAccount] = useState<string>(null);
const [events, setEvents] = useState<LumeEvent[]>([]);
const texts = useMemo(
() => events.filter((ev) => ev.kind === Kind.Text),
[events],
);
const zaps = useMemo(() => {
const groups = new Map<string, LumeEvent[]>();
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, LumeEvent[]>();
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]);
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Open Lume",
action: () => LumeWindow.openMainWindow(),
}),
MenuItem.new({
text: "New Post",
action: () => LumeWindow.openEditor(),
}),
MenuItem.new({
text: "Search",
action: () => LumeWindow.openSearch(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "About Lume",
action: async () => await open("https://lume.nu"),
}),
MenuItem.new({
text: "Check for Updates",
action: async () => await checkForAppUpdates(false),
}),
MenuItem.new({
text: "Settings",
action: () => LumeWindow.openSettings(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Quit",
action: async () => await exit(0),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
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: LumeEvent = JSON.parse(data.payload as string);
setEvents((prev) => [event, ...prev]);
});
return () => {
unlistenLoad.then((f) => f());
unlistenNewEvent.then((f) => f());
};
}, []);
if (!account) {
return (
<div className="flex items-center justify-center w-full h-full text-sm">
Please log in.
</div>
);
}
return (
<div className="flex flex-col w-full h-full">
<div className="flex items-center justify-between px-4 border-b h-11 shrink-0 border-black/5 dark:border-white/5">
<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="rounded-full size-7" />
</User.Root>
</User.Provider>
<button
type="button"
onClick={() => LumeWindow.openSearch()}
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
>
<SearchIcon className="size-4" />
</button>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
>
<HorizontalDotsIcon className="size-4" />
</button>
</div>
</div>
<Tabs.Root
defaultValue="replies"
className="flex-1 overflow-x-hidden overflow-y-auto 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 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 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 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 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 dark:border-white/10 data-[state=active]:border-black/30 dark:data-[state=active]:border-white/30 data-[state=inactive]:opacity-50"
value="zaps"
>
Zaps
</Tabs.Trigger>
</Tabs.List>
<div className="h-full">
<Tab value="replies">
{texts.map((event, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<TextNote key={event.id + index} event={event} />
))}
</Tab>
<Tab value="reactions">
{[...reactions.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider key={event.id} 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 rounded-full size-7" />
<div className="inline-flex items-center justify-center flex-1 text-xs truncate rounded-full size-7">
{event.kind === Kind.Reaction ? (
event.content === "+" ? (
"👍"
) : (
event.content
)
) : (
<RepostIcon className="text-teal-400 size-4 dark:text-teal-600" />
)}
</div>
</User.Root>
</User.Provider>
))}
</div>
</div>
</div>
))}
</Tab>
<Tab value="zaps">
{[...zaps.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 backdrop-blur-md bg-black/10 dark:bg-white/10"
>
<div className="flex flex-col flex-1 min-w-0 gap-2">
<div className="flex items-center gap-2 pb-2 border-b border-black/5 dark:border-white/5">
<RootNote id={root} />
</div>
<div className="flex flex-wrap items-center gap-3">
{events.map((event) => (
<User.Provider
key={event.id}
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 rounded-full size-7" />
<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>
))}
</Tab>
</div>
</Tabs.Root>
</div>
);
}
function Tab({ value, children }: { value: string; children: ReactNode[] }) {
const ref = useRef<HTMLDivElement>(null);
return (
<Tabs.Content value={value} className="size-full">
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="h-full px-2 pt-2">
<Virtualizer scrollRef={ref}>{children}</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</Tabs.Content>
);
}
function RootNote({ id }: { id: string }) {
const { isLoading, isError, data } = useEvent(id);
if (isLoading) {
return (
<div className="flex items-center pb-2 mb-2">
<div className="rounded-full size-8 shrink-0 bg-black/20 dark:bg-white/20 animate-pulse" />
<div className="w-2/3 h-4 rounded-md animate-pulse bg-black/20 dark:bg-white/20" />
</div>
);
}
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<div className="inline-flex items-center justify-center text-white bg-red-500 rounded-full size-8 shrink-0">
<InfoIcon className="size-5" />
</div>
<p className="text-sm text-red-500">
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="rounded-full size-8 shrink-0" />
</User.Root>
</User.Provider>
<div className="line-clamp-1">{data.content}</div>
</Note.Root>
</Note.Provider>
);
}
function TextNote({ event }: { event: LumeEvent }) {
const pTags = event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1])
.slice(0, 3);
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col p-2 mb-2 rounded-lg shrink-0 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="rounded-full size-9 shrink-0" />
<div className="flex flex-col flex-1">
<div className="flex items-baseline justify-between w-full">
<User.Name className="text-sm font-semibold leading-tight" />
<span className="text-sm leading-tight 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 key={replyTo} pubkey={replyTo}>
<User.Root>
<User.Name className="font-medium leading-tight" />
</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 text-start">{event.content}</div>
</div>
</Note.Root>
</Note.Provider>
);
}
+29 -26
View File
@@ -1,11 +1,12 @@
import { SearchIcon } from "@lume/icons";
import { type Event, Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { SearchIcon } from "@lume/icons";
import { LumeEvent, LumeWindow } from "@lume/system";
import { Kind, type NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
export const Route = createFileRoute("/search")({ export const Route = createFileRoute("/search")({
@@ -14,7 +15,7 @@ export const Route = createFileRoute("/search")({
function Screen() { function Screen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<LumeEvent[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500); const [searchValue] = useDebounce(search, 500);
@@ -25,14 +26,18 @@ function Screen() {
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`; const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query); const res = await fetch(query);
const content = await res.json(); 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); const lumeEvents = events.map((ev) => new LumeEvent(ev));
const sorted = lumeEvents.sort((a, b) => b.created_at - a.created_at);
setLoading(false); setLoading(false);
setEvents(sorted); setEvents(sorted);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Search",
kind: "error",
});
} }
}; };
@@ -44,7 +49,7 @@ function Screen() {
return ( return (
<div data-tauri-drag-region className="flex flex-col w-full h-full"> <div data-tauri-drag-region className="flex flex-col w-full h-full">
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5"> <div className="relative flex flex-col h-24 border-b shrink-0 border-black/5 dark:border-white/5">
<div data-tauri-drag-region className="w-full h-4 shrink-0" /> <div data-tauri-drag-region className="w-full h-4 shrink-0" />
<input <input
value={search} value={search}
@@ -53,12 +58,12 @@ function Screen() {
if (e.key === "Enter") searchEvents(); if (e.key === "Enter") searchEvents();
}} }}
placeholder="Search anything..." placeholder="Search anything..."
className="w-full h-20 pt-10 px-3 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600" className="w-full h-20 px-3 pt-10 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
/> />
</div> </div>
<div className="flex-1 p-3 overflow-y-auto scrollbar-none"> <div className="flex-1 p-3 overflow-y-auto scrollbar-none">
{loading ? ( {loading ? (
<div className="w-full h-full flex items-center justify-center"> <div className="flex items-center justify-center w-full h-full">
<Spinner /> <Spinner />
</div> </div>
) : events.length ? ( ) : events.length ? (
@@ -67,11 +72,11 @@ function Screen() {
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0"> <div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Users Users
</div> </div>
<div className="flex-1 flex flex-col gap-1"> <div className="flex flex-col flex-1 gap-1">
{events {events
.filter((ev) => ev.kind === Kind.Metadata) .filter((ev) => ev.kind === Kind.Metadata)
.map((event, index) => ( .map((event) => (
<SearchUser key={event.pubkey + index} event={event} /> <SearchUser key={event.pubkey} event={event} />
))} ))}
</div> </div>
</div> </div>
@@ -79,7 +84,7 @@ function Screen() {
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0"> <div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Notes Notes
</div> </div>
<div className="flex-1 flex flex-col gap-3"> <div className="flex flex-col flex-1 gap-3">
{events {events
.filter((ev) => ev.kind === Kind.Text) .filter((ev) => ev.kind === Kind.Text)
.map((event) => ( .map((event) => (
@@ -90,8 +95,8 @@ function Screen() {
</div> </div>
) : null} ) : null}
{!loading && !events.length ? ( {!loading && !events.length ? (
<div className="h-full flex items-center justify-center flex-col gap-3"> <div className="flex flex-col items-center justify-center h-full gap-3">
<div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center"> <div className="inline-flex items-center justify-center rounded-full size-16 bg-black/10 dark:bg-white/10">
<SearchIcon className="size-6" /> <SearchIcon className="size-6" />
</div> </div>
Try searching for people, notes, or keywords Try searching for people, notes, or keywords
@@ -102,19 +107,17 @@ function Screen() {
); );
} }
function SearchUser({ event }: { event: Event }) { function SearchUser({ event }: { event: LumeEvent }) {
const { ark } = Route.useRouteContext();
return ( return (
<button <button
key={event.id} key={event.id}
type="button" 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" className="col-span-1 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10"
> >
<User.Provider pubkey={event.pubkey} embedProfile={event.content}> <User.Provider pubkey={event.pubkey} embedProfile={event.content}>
<User.Root className="flex items-center gap-2"> <User.Root className="flex items-center gap-2">
<User.Avatar className="size-9 rounded-full shrink-0" /> <User.Avatar className="rounded-full size-9 shrink-0" />
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<User.Name className="font-semibold" /> <User.Name className="font-semibold" />
<User.NIP05 /> <User.NIP05 />
@@ -125,17 +128,17 @@ function SearchUser({ event }: { event: Event }) {
); );
} }
function SearchNote({ event }: { event: Event }) { function SearchNote({ event }: { event: LumeEvent }) {
return ( return (
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> <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}> <Note.Provider event={event}>
<Note.Root> <Note.Root>
<div className="px-3 h-14 flex items-center justify-between"> <div className="flex items-center justify-between px-3 h-14">
<Note.User /> <Note.User />
<Note.Menu /> <Note.Menu />
</div> </div>
<Note.Content className="px-3" quote={false} mention={false} /> <Note.Content className="px-3" quote={false} mention={false} />
<div className="mt-3 flex items-center gap-4 h-14 px-3"> <div className="flex items-center gap-4 px-3 mt-3 h-14">
<Note.Open /> <Note.Open />
</div> </div>
</Note.Root> </Note.Root>
+5 -7
View File
@@ -18,10 +18,10 @@ function Screen() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex h-full w-full flex-col"> <div className="flex flex-col w-full h-full">
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-black/10 dark:border-white/10" className="flex items-center justify-center w-full h-20 border-b shrink-0 border-black/10 dark:border-white/10"
> >
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Link to="/settings/general"> <Link to="/settings/general">
@@ -79,7 +79,7 @@ function Screen() {
); );
}} }}
</Link> </Link>
<Link to="/settings/zap"> <Link to="/settings/wallet">
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
@@ -91,9 +91,7 @@ function Screen() {
)} )}
> >
<ZapIcon className="size-5 shrink-0" /> <ZapIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">Wallet</p>
{t("settings.zap.title")}
</p>
</div> </div>
); );
}} }}
@@ -119,7 +117,7 @@ function Screen() {
</Link> </Link>
</div> </div>
</div> </div>
<div className="w-full flex-1 overflow-y-auto scrollbar-none px-5 py-4"> <div className="flex-1 w-full px-5 py-4 overflow-y-auto scrollbar-none">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
+33 -87
View File
@@ -1,127 +1,73 @@
import { User } from "@/components/user"; import { User } from "@/components/user";
import type { Account } from "@lume/types"; import { NostrAccount } from "@lume/system";
import { displayNsec } from "@lume/utils"; import { displayNpub } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
interface Account {
npub: string;
nsec: string;
}
export const Route = createFileRoute("/settings/backup")({ export const Route = createFileRoute("/settings/backup")({
component: Screen, beforeLoad: async () => {
loader: async ({ context }) => { const accounts = await NostrAccount.getAccounts();
const ark = context.ark; return { accounts };
const npubs = await ark.get_accounts();
const accounts: Account[] = [];
for (const account of npubs) {
const nsec: string = await invoke("get_stored_nsec", {
npub: account.npub,
});
accounts.push({ ...account, nsec });
}
return accounts;
}, },
component: Screen,
}); });
function Screen() { function Screen() {
const accounts = Route.useLoaderData(); const { accounts } = Route.useRouteContext();
return ( return (
<div className="mx-auto w-full max-w-xl"> <div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700"> <div className="flex flex-col gap-3 divide-y divide-neutral-300 dark:divide-neutral-700">
{accounts.map((account) => ( {accounts.map((account) => (
<NostrAccount key={account.npub} account={account} /> <Account key={account} account={account} />
))} ))}
</div> </div>
</div> </div>
); );
} }
function NostrAccount({ account }: { account: Account }) { function Account({ account }: { account: string }) {
const [key, setKey] = useState(account.nsec);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [passphase, setPassphase] = useState("");
const encrypt = async () => {
const encrypted: string = await invoke("get_encrypted_key", {
npub: account.npub,
password: passphase,
});
setKey(encrypted);
};
const copyKey = async () => { const copyKey = async () => {
try { try {
await writeText(key); const data: string = await invoke("get_private_key", { npub: account });
await writeText(data);
setCopied(true); setCopied(true);
} catch (e) { } catch (e) {
toast.error(e); await message(String(e), { title: "Backup", kind: "error" });
} }
}; };
return ( return (
<div className="flex flex-1 flex-col gap-2 py-3"> <div className="flex items-center justify-between gap-2 py-3">
<User.Provider pubkey={account.npub}> <User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2"> <User.Root className="flex items-center gap-2">
<User.Avatar className="size-8 rounded-full object-cover" /> <User.Avatar className="object-cover rounded-full size-8" />
<div className="flex flex-col"> <div className="flex flex-col">
<User.Name className="text-sm leading-tight" /> <User.Name className="text-sm leading-tight" />
<User.NIP05 /> <span className="text-sm leading-tight text-black/50 dark:text-white/50">
{displayNpub(account, 16)}
</span>
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<div className="flex flex-col gap-2"> <div className="flex items-center gap-2">
<div className="flex w-full flex-col gap-1"> <button
<label type="button"
htmlFor="nsec" onClick={() => copyKey()}
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="inline-flex items-center justify-center h-8 text-sm font-medium rounded-md w-36 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/10 dark:hover:bg-white/20"
> >
Private Key {copied ? "Copied" : "Copy Private Key"}
</label> </button>
<div className="flex items-center gap-2">
<input
readOnly
name="nsec"
type="text"
value={displayNsec(key, 36)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex w-full flex-col gap-1">
<label
htmlFor="passphase"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set a passphase to secure your key
</label>
<div className="flex items-center gap-2">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => encrypt()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,39 @@
import { Button, init } from "@getalby/bitcoin-connect-react";
import { NostrAccount } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
export const Route = createFileRoute("/settings/bitcoin-connect")({
beforeLoad: () => {
init({
appName: "Lume",
filters: ["nwc"],
showBalance: true,
});
},
component: Screen,
});
function Screen() {
const setNwcUri = async (uri: string) => {
const cmd = await NostrAccount.setWallet(uri);
if (cmd) getCurrent().close();
};
return (
<div className="flex items-center justify-center size-full">
<div className="flex flex-col items-center justify-center gap-3 text-center">
<div>
<p className="text-sm text-black/70 dark:text-white/70">
Click to the button below to connect with your Bitcoin wallet.
</p>
</div>
<Button
onConnected={(provider) =>
setNwcUri(provider.client.nostrWalletConnectUrl)
}
/>
</div>
</div>
);
}
+195 -125
View File
@@ -1,147 +1,102 @@
import type { Settings } from "@lume/types"; import { NostrQuery, type Settings } from "@lume/system";
import * as Switch from "@radix-ui/react-switch"; import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { import { invoke } from "@tauri-apps/api/core";
isPermissionGranted,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
export const Route = createFileRoute("/settings/general")({ type Theme = "auto" | "light" | "dark";
beforeLoad: async ({ context }) => {
const permissionGranted = await isPermissionGranted(); // get notification permission
const ark = context.ark;
const settings = await ark.get_settings();
return { export const Route = createFileRoute("/settings/general")({
settings: { ...settings, notification: permissionGranted }, beforeLoad: async () => {
}; const initialSettings = await NostrQuery.getUserSettings();
return { initialSettings };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, settings } = Route.useRouteContext(); const { initialSettings } = Route.useRouteContext();
const [newSettings, setNewSettings] = useState<Settings>(settings);
const toggleNofitication = async () => { const [theme, setTheme] = useState<Theme>(null);
await requestPermission(); const [settings, setSettings] = useState<Settings>(null);
setNewSettings((prev) => ({
...prev, const changeTheme = async (theme: string) => {
notification: !newSettings.notification, if (theme === "auto" || theme === "light" || theme === "dark") {
})); invoke("plugin:theme|set_theme", {
theme: theme,
}).then(() => setTheme(theme));
}
}; };
const toggleAutoUpdate = () => { const updateSettings = useDebouncedCallback(async () => {
setNewSettings((prev) => ({ const newSettings = JSON.stringify(settings);
...prev, await NostrQuery.setUserSettings(newSettings);
autoUpdate: !newSettings.autoUpdate,
}));
};
const toggleEnhancedPrivacy = () => {
setNewSettings((prev) => ({
...prev,
enhancedPrivacy: !newSettings.enhancedPrivacy,
}));
};
const toggleZap = () => {
setNewSettings((prev) => ({
...prev,
zap: !newSettings.zap,
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const updateSettings = useDebouncedCallback(() => {
ark.set_settings(newSettings);
}, 200); }, 200);
useEffect(() => { useEffect(() => {
updateSettings(); updateSettings();
}, [newSettings]); }, [settings]);
useEffect(() => {
invoke("plugin:theme|get_theme").then((data: Theme) => setTheme(data));
}, []);
useEffect(() => {
setSettings(initialSettings);
}, [initialSettings]);
if (!settings) return null;
return ( return (
<div className="mx-auto w-full max-w-xl"> <div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center w-full px-3 text-sm rounded-lg h-11 bg-black/5 dark:bg-white/5">
* Setting changes require restarting the app to take effect.
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300"> <h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
General General
</h2> </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 px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex w-full items-start justify-between gap-4 py-3"> <div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">Notification</h3> <h3 className="font-medium">Relay Hint</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-sm text-neutral-700 dark:text-neutral-300">
By turning on push notifications, you'll start getting Use the relay hint if necessary.
notifications from Lume directly.
</p> </p>
</div> </div>
<div className="w-36 flex justify-end shrink-0"> <div className="flex justify-end w-36 shrink-0">
<Switch.Root <Switch.Root
checked={newSettings.notification} checked={settings.use_relay_hint}
onClick={() => toggleNofitication()} onClick={() =>
setSettings((prev) => ({
...prev,
use_relay_hint: !prev.use_relay_hint,
}))
}
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" 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.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> </Switch.Root>
</div> </div>
</div> </div>
<div className="flex w-full items-start justify-between gap-4 py-3"> <div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">Enhanced Privacy</h3> <h3 className="font-medium">Content Warning</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-sm text-neutral-700 dark:text-neutral-300">
Lume presents external resources like images, videos, or link Shows a warning for notes that have a content warning.
previews in plain text.
</p> </p>
</div> </div>
<div className="w-36 flex justify-end shrink-0"> <div className="flex justify-end w-36 shrink-0">
<Switch.Root <Switch.Root
checked={newSettings.enhancedPrivacy} checked={settings.content_warning}
onClick={() => toggleEnhancedPrivacy()} onClick={() =>
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" setSettings((prev) => ({
> ...prev,
<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]" /> content_warning: !prev.content_warning,
</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">Auto Update</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Automatically download and install new version.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.autoUpdate}
onClick={() => toggleAutoUpdate()}
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">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
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" 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.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]" />
@@ -151,28 +106,143 @@ function Screen() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300"> <h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Interface Appearance
</h2> </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 px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex flex-col gap-4"> <div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex w-full items-start justify-between gap-4 py-3"> <div className="flex-1">
<div className="flex-1"> <h3 className="font-medium">Appearance</h3>
<h3 className="font-semibold">Zap</h3> <p className="text-sm text-neutral-700 dark:text-neutral-300">
<p className="text-sm text-neutral-700 dark:text-neutral-300"> Require restarting the app to take effect.
Show the Zap button in each note and user's profile screen, </p>
use for send bitcoin tip to other users. </div>
</p> <div className="flex justify-end w-36 shrink-0">
</div> <select
<div className="w-36 flex justify-end shrink-0"> name="theme"
<Switch.Root className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
checked={newSettings.zap} defaultValue={theme}
onClick={() => toggleZap()} onChange={(e) => changeTheme(e.target.value)}
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" >
> <option value="auto">Auto</option>
<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]" /> <option value="light">Light</option>
</Switch.Root> <option value="dark">Dark</option>
</div> </select>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Zap Button</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Shows the Zap button when viewing a note.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_zap_button}
onClick={() =>
setSettings((prev) => ({
...prev,
display_zap_button: !prev.display_zap_button,
}))
}
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 items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Repost Button</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Shows the Repost button when viewing a note.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_zap_button}
onClick={() =>
setSettings((prev) => ({
...prev,
display_zap_button: !prev.display_zap_button,
}))
}
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>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Privacy & Performance
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Proxy</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Set proxy address.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<input
type="url"
defaultValue={settings.proxy}
onChange={(e) =>
setSettings((prev) => ({
...prev,
proxy: e.target.value,
}))
}
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
/>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Image Resize Service</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Use weserv/images for resize image on-the-fly.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<input
type="url"
defaultValue={settings.image_resize_service}
onChange={(e) =>
setSettings((prev) => ({
...prev,
image_resize_service: e.target.value,
}))
}
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
/>
</div>
</div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Load Remote Media</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
View the remote media directly.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.display_media}
onClick={() =>
setSettings((prev) => ({
...prev,
display_image_link: !prev.display_media,
}))
}
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> </div>
</div> </div>
+37 -27
View File
@@ -1,14 +1,13 @@
import { CancelIcon, PlusIcon } from "@lume/icons"; import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/relay")({ export const Route = createFileRoute("/settings/relay")({
loader: async ({ context }) => { loader: async () => {
const ark = context.ark; const relays = await NostrQuery.getRelays();
const relays = await ark.get_relays();
return relays; return relays;
}, },
component: Screen, component: Screen,
@@ -16,47 +15,57 @@ export const Route = createFileRoute("/settings/relay")({
function Screen() { function Screen() {
const relayList = Route.useLoaderData(); const relayList = Route.useLoaderData();
const [relays, setRelays] = useState(relayList.connected);
const { ark } = Route.useRouteContext();
const { register, reset, handleSubmit } = useForm(); const { register, reset, handleSubmit } = useForm();
const [relays, setRelays] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const onSubmit = async (data: { url: string }) => { const onSubmit = async (data: { url: string }) => {
try { try {
const add = await ark.add_relay(data.url); setIsLoading(true);
const add = await NostrQuery.connectRelay(data.url);
if (add) { if (add) {
setRelays((prev) => [...prev, data.url]); setRelays((prev) => [...prev, data.url]);
setIsLoading(false);
reset(); reset();
} }
} catch (e) { } catch (e) {
toast.error(String(e)); setIsLoading(false);
await message(String(e), { title: "Relay", kind: "error" });
} }
}; };
useEffect(() => {
setRelays(relayList.connected);
}, [relayList]);
return ( return (
<div className="mx-auto w-full max-w-xl"> <div className="w-full max-w-xl mx-auto">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300"> <h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Connected Relays Connected Relays
</h2> </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 px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
{relays.map((relay) => ( {relays.map((relay) => (
<div <div
key={relay} key={relay}
className="flex justify-between items-center h-11" className="flex items-center justify-between h-11"
> >
<div className="inline-flex items-center gap-2 text-sm font-medium"> <div className="inline-flex items-center gap-2 text-sm font-medium">
<span className="relative flex size-2"> <span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span> <span className="absolute inline-flex w-full h-full bg-teal-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex rounded-full size-2 bg-teal-500"></span> <span className="relative inline-flex bg-teal-500 rounded-full size-2" />
</span> </span>
{relay} {relay}
</div> </div>
<div> <div>
<button <button
type="button" type="button"
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10" onClick={() => NostrQuery.removeRelay(relay)}
className="inline-flex items-center justify-center rounded-md size-7 hover:bg-black/10 dark:hover:bg-white/10"
> >
<CancelIcon className="size-4" /> <CancelIcon className="size-4" />
</button> </button>
@@ -66,7 +75,7 @@ function Screen() {
<div className="flex items-center h-14"> <div className="flex items-center h-14">
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="w-full flex items-center gap-2 mb-0" className="flex items-center w-full gap-2 mb-0"
> >
<input <input
{...register("url", { {...register("url", {
@@ -76,11 +85,12 @@ function Screen() {
name="url" name="url"
placeholder="wss://..." placeholder="wss://..."
spellCheck={false} spellCheck={false}
className="h-9 flex-1 rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400" className="flex-1 px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
/> />
<button <button
type="submit" type="submit"
className="shrink-0 inline-flex h-9 w-16 px-2 items-center justify-center rounded-lg bg-black/20 dark:bg-white/20 font-medium text-sm text-white hover:bg-blue-500 disabled:opacity-50" disabled={isLoading}
className="inline-flex items-center justify-center w-16 px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
> >
<PlusIcon className="size-7" /> <PlusIcon className="size-7" />
</button> </button>
@@ -89,21 +99,21 @@ function Screen() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="font-semibold text-sm text-neutral-700 dark:text-neutral-300"> <h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
User Relays (NIP-65) User Relays (NIP-65)
</h2> </h2>
<div className="flex flex-col py-2 bg-black/5 dark:bg-white/5 rounded-xl px-3"> <div className="flex flex-col px-3 py-2 bg-black/5 dark:bg-white/5 rounded-xl">
<p className="text-sm text-yellow-500"> <p className="text-sm text-yellow-500">
Lume will automatically connect to the user's relay list, but the Lume will automatically connect to the user's relay list, but the
manager function (like adding, removing, changing relay purpose) manager function (like adding, removing, changing relay purpose)
is not yet available. is not yet available.
</p> </p>
</div> </div>
<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 px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
{relayList.read?.map((relay) => ( {relayList.read?.map((relay) => (
<div <div
key={relay} key={relay}
className="flex justify-between items-center h-11" className="flex items-center justify-between h-11"
> >
<div className="text-sm font-medium">{relay}</div> <div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ</div> <div className="text-xs font-semibold">READ</div>
@@ -112,7 +122,7 @@ function Screen() {
{relayList.write?.map((relay) => ( {relayList.write?.map((relay) => (
<div <div
key={relay} key={relay}
className="flex justify-between items-center h-11" className="flex items-center justify-between h-11"
> >
<div className="text-sm font-medium">{relay}</div> <div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">WRITE</div> <div className="text-xs font-semibold">WRITE</div>
@@ -121,7 +131,7 @@ function Screen() {
{relayList.both?.map((relay) => ( {relayList.both?.map((relay) => (
<div <div
key={relay} key={relay}
className="flex justify-between items-center h-11" className="flex items-center justify-between h-11"
> >
<div className="text-sm font-medium">{relay}</div> <div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ + WRITE</div> <div className="text-xs font-semibold">READ + WRITE</div>
+26 -26
View File
@@ -1,24 +1,24 @@
import { AvatarUploader } from "@/components/avatarUploader"; import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons"; import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types"; import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
export const Route = createFileRoute("/settings/user")({ export const Route = createFileRoute("/settings/user")({
beforeLoad: async ({ context }) => { beforeLoad: async () => {
const ark = context.ark; const profile = await NostrAccount.getProfile();
const profile = await ark.get_current_user_profile();
return { profile }; return { profile };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { ark, profile } = Route.useRouteContext(); const { profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm({ defaultValues: profile }); const { register, handleSubmit } = useForm({ defaultValues: profile });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -29,36 +29,36 @@ function Screen() {
setLoading(true); setLoading(true);
const newProfile: Metadata = { ...profile, ...data, picture }; const newProfile: Metadata = { ...profile, ...data, picture };
await ark.create_profile(newProfile); await NostrAccount.createProfile(newProfile);
setLoading(false); setLoading(false);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), { title: "Profile", kind: "error" });
} }
}; };
return ( return (
<div className="flex w-full h-full"> <div className="flex w-full h-full">
<div className="flex-1 h-full flex items-center flex-col justify-center gap-3"> <div className="flex flex-col items-center justify-center flex-1 h-full gap-3">
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200"> <div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? ( {profile.picture ? (
<img <img
src={picture || profile.picture} src={picture || profile.picture}
alt="avatar" alt="avatar"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover" className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/> />
) : null} ) : null}
<AvatarUploader <AvatarUploader
setPicture={setPicture} setPicture={setPicture}
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
> >
<PlusIcon className="size-8" /> <PlusIcon className="size-8" />
</AvatarUploader> </AvatarUploader>
</div> </div>
<div className="text-center flex flex-col items-center"> <div className="flex flex-col items-center text-center">
<div className="text-lg font-semibold">{profile.display_name}</div> <div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-800 dark:text-neutral-200"> <div className="text-neutral-800 dark:text-neutral-200">
{profile.nip05} {profile.nip05}
@@ -66,7 +66,7 @@ function Screen() {
<div className="mt-4"> <div className="mt-4">
<Link <Link
to="/settings/backup" to="/settings/backup"
className="px-5 h-9 border border-blue-300 text-sm font-medium hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 rounded-full bg-blue-100 text-blue-500 inline-flex items-center justify-center" className="inline-flex items-center justify-center px-5 text-sm font-medium text-blue-500 bg-blue-100 border border-blue-300 rounded-full h-9 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
> >
Backup Account Backup Account
</Link> </Link>
@@ -78,7 +78,7 @@ function Screen() {
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0" className="flex flex-col gap-3 mb-0"
> >
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="display_name" htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -89,10 +89,10 @@ function Screen() {
name="display_name" name="display_name"
{...register("display_name")} {...register("display_name")}
spellCheck={false} spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="name" htmlFor="name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -103,10 +103,10 @@ function Screen() {
name="name" name="name"
{...register("name")} {...register("name")}
spellCheck={false} spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="website" htmlFor="website"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -118,10 +118,10 @@ function Screen() {
type="url" type="url"
{...register("website")} {...register("website")}
spellCheck={false} spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="banner" htmlFor="banner"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -133,10 +133,10 @@ function Screen() {
type="url" type="url"
{...register("banner")} {...register("banner")}
spellCheck={false} spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="nip05" htmlFor="nip05"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -148,10 +148,10 @@ function Screen() {
type="email" type="email"
{...register("nip05")} {...register("nip05")}
spellCheck={false} spellCheck={false}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex w-full flex-col gap-1"> <div className="flex flex-col w-full gap-1">
<label <label
htmlFor="lnaddress" htmlFor="lnaddress"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300" className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
@@ -162,13 +162,13 @@ function Screen() {
name="lnaddress" name="lnaddress"
type="email" type="email"
{...register("lud16")} {...register("lud16")}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800" className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/> />
</div> </div>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<button <button
type="submit" type="submit"
className="inline-flex h-9 w-32 px-2 items-center justify-center rounded-lg bg-blue-500 font-medium text-sm text-white hover:bg-blue-600 disabled:opacity-50" className="inline-flex items-center justify-center w-32 px-2 text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600 disabled:opacity-50"
> >
{loading ? <Spinner className="size-4" /> : "Update Profile"} {loading ? <Spinner className="size-4" /> : "Update Profile"}
</button> </button>
@@ -0,0 +1,59 @@
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/settings/wallet")({
beforeLoad: async () => {
const wallet = await NostrAccount.loadWallet();
if (!wallet) {
throw redirect({ to: "/settings/bitcoin-connect" });
}
const balance = getBitcoinDisplayValues(wallet);
return { balance };
},
component: Screen,
});
function Screen() {
const { balance } = Route.useRouteContext();
const disconnect = async () => {
window.localStorage.removeItem("bc:config");
await NostrAccount.removeWallet();
return redirect({ to: "/settings/bitcoin-connect" });
};
return (
<div className="w-full max-w-xl mx-auto">
<div className="flex flex-col w-full gap-3">
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Connection</h3>
</div>
<div className="flex justify-end w-36 shrink-0">
<button
type="button"
onClick={() => disconnect()}
className="h-8 w-max px-2.5 text-sm rounded-lg inline-flex items-center justify-center bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
>
Disconnect
</button>
</div>
</div>
</div>
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Current Balance</h3>
</div>
<div className="flex justify-end w-36 shrink-0">
{balance.bitcoinFormatted}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,102 +0,0 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/settings/zap")({
component: Screen,
});
function Screen() {
return (
<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">
<div className="flex flex-col gap-6 py-3">
<Connection />
<DefaultAmount />
</div>
</div>
</div>
);
}
function Connection() {
const [uri, setUri] = useState("");
const connect = async () => {
try {
await invoke("set_nwc", { uri });
} catch (e) {
toast.error(String(e));
}
};
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm">
Connection
</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="nwc"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Nostr Wallet Connect
</label>
<div className="flex items-center gap-2">
<input
name="nwc"
type="text"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
onClick={() => connect()}
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Connect
</button>
</div>
</div>
</div>
</div>
);
}
function DefaultAmount() {
return (
<div className="flex items-start gap-6">
<div className="w-36 shrink-0 text-end font-medium text-sm">
Default amount
</div>
<div className="flex-1">
<div className="flex w-full flex-col gap-1">
<label
htmlFor="amount"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Set default amount for quick zapping
</label>
<div className="flex items-center gap-2">
<input
name="amount"
type="number"
value={21}
className="h-9 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<button
type="button"
className="inline-flex h-9 w-24 items-center justify-center rounded-lg bg-neutral-200 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
>
Update
</button>
</div>
</div>
</div>
</div>
);
}
+1 -9
View File
@@ -1,17 +1,9 @@
import { GlobalIcon, LaurelIcon } from "@lume/icons"; import { GlobalIcon, LaurelIcon } from "@lume/icons";
import type { ColumnRouteSearch } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store")({ export const Route = createFileRoute("/store")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return {
account: search.account,
label: search.label,
name: search.name,
};
},
component: Screen, component: Screen,
}); });
@@ -19,7 +11,7 @@ function Screen() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="px-3 mt-2 mb-1"> <div className="px-3 mt-2 mb-1">
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5"> <div className="inline-flex items-center w-full gap-1 p-1 rounded-lg shrink-0 bg-black/5 dark:bg-white/5">
<Link to="/store/official" className="flex-1"> <Link to="/store/official" className="flex-1">
{({ isActive }) => ( {({ isActive }) => (
<div <div

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