Compare commits

...
73 Commits
Author SHA1 Message Date
reya bdf3ffd7bf fix: tray panel is missing 2024-07-19 13:58:32 +07:00
reya 07ce253f5b fix: child webview is not reposition after scroll 2024-07-19 13:10:29 +07:00
reya f3db010c74 chore: update tauri config 2024-07-19 10:01:20 +07:00
reya dcf2791fe5 fix: build 2024-07-19 09:28:03 +07:00
reya 8fcf3551d8 chore: bump version 2024-07-19 08:33:46 +07:00
reya 2d987849d8 feat: use native border in macos 2024-07-19 08:33:16 +07:00
reya 3b99926f3b feat: adapt latest changes in tauri v2 2024-07-19 08:25:36 +07:00
reya 113d69a4df chore: update deps 2024-07-18 18:57:28 +07:00
reya 5d12ba7216 fix: some screen too large 2024-07-03 14:36:54 +07:00
reya 72b59020b4 fix: build on linux and windows 2024-07-03 14:09:07 +07:00
XIAO YUandGitHub 4c323b9daa chore: Refactor code to use HashSet for account search results (#222) 2024-07-03 07:33:16 +07:00
reya 72da83d648 feat: add force quit command 2024-07-02 17:24:55 +07:00
reya 783a4538a4 Revert "chore: update ci"
This reverts commit 04706a6d7c.
2024-07-02 15:54:00 +07:00
reya 15e62cad11 fix: temporary include devtools in release build 2024-07-02 14:58:00 +07:00
reya c52b20ca80 chore: bump version 2024-07-02 14:36:16 +07:00
reya 04706a6d7c chore: update ci 2024-07-02 14:07:12 +07:00
reya 0755cbeb6c chore: bump version 2024-07-02 13:03:45 +07:00
雨宮蓮andGitHub 8eb01c8bbf Improve column management (#221)
* wip: redesign store

* feat: update trending column

* feat: add more functions
2024-07-02 12:51:50 +07:00
reya ed4f89ff66 feat: add option to toggle window transparent 2024-07-02 08:49:52 +07:00
reya d9fe647f8e feat: few improvements 2024-07-01 14:41:33 +07:00
reya 843c2d52e7 refactor: tray panel 2024-07-01 13:04:32 +07:00
reya 017a3676a4 feat: optimize spawn thread 2024-06-30 21:06:04 +07:00
reya fcb70c0e9a chore: update deps 2024-06-30 14:37:26 +07:00
雨宮蓮andGitHub 0fec21b9ce Some improments and Negentropy (#219)
* feat: adjust default window size

* feat: save window state

* feat: add window state plugin

* feat: add search

* feat: use negentropy for newsfeed

* feat: live feeds

* feat: add search user
2024-06-30 14:26:02 +07:00
XIAO YUandGitHub 968b1ada94 refactor: improve relay management code structure (#220) 2024-06-29 07:41:16 +07:00
reya 5c9b599b1e feat: use memo for some components 2024-06-26 17:49:36 +07:00
雨宮蓮andGitHub 717c3e17df Event Subscriptions (#218)
* feat: improve create column command

* refactor: thread

* feat: add window virtualized to event screen

* chore: update deps

* fix: window decoration

* feat: improve mention ntoe

* feat: add subscription to event screen
2024-06-26 14:51:50 +07:00
XIAO YUandGitHub a4540a0802 refactor: improve error handling in event.rs (#217) 2024-06-26 07:57:53 +07:00
reya 31bacc2646 chore: remove unused code 2024-06-24 11:09:32 +07:00
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
176 changed files with 14590 additions and 12423 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/
+27 -31
View File
@@ -9,56 +9,52 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@lume/system": "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.1.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3", "@tanstack/query-persist-client-core": "^5.51.9",
"@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-query": "^5.51.9",
"@tanstack/query-sync-storage-persister": "^5.36.0", "@tanstack/react-router": "^1.45.4",
"@tanstack/react-query": "^5.36.0", "embla-carousel-react": "^8.1.6",
"@tanstack/react-query-persist-client": "^5.36.0", "i18next": "^23.12.1",
"@tanstack/react-router": "1.32.5",
"i18next": "^23.11.4",
"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.1",
"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.1",
"react-hotkeys-hook": "^4.5.0", "react-i18next": "^14.1.3",
"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.45.4",
"@tanstack/router-vite-plugin": "^1.32.2", "@tanstack/router-vite-plugin": "^1.45.3",
"@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.39",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.6",
"typescript": "^5.4.5", "typescript": "^5.5.3",
"vite": "^5.2.11", "vite": "^5.3.4",
"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.

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

-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;
+8 -27
View File
@@ -1,38 +1,19 @@
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 React, { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { I18nextProvider } from "react-i18next";
import "./app.css";
import i18n from "./locale";
import { routeTree } from "./router.gen"; // auto generated file
import { type } from "@tauri-apps/plugin-os"; import { type } from "@tauri-apps/plugin-os";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { routeTree } from "./router.gen"; // auto generated file
import "./app.css";
const os = await type();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const persister = createSyncStoragePersister({ const platform = type();
storage: window.localStorage,
});
// Set up a Router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: { context: { queryClient, platform },
queryClient,
platform: os,
},
Wrap: ({ children }) => { Wrap: ({ children }) => {
return ( return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
{children}
</PersistQueryClientProvider>
</I18nextProvider>
); );
}, },
}); });
@@ -1,13 +1,13 @@
import { NostrQuery } from "@lume/system"; 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 { 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,
@@ -27,7 +27,7 @@ export function AvatarUploader({
setPicture(image); setPicture(image);
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), { title: "Lume", kind: "error" });
} }
}; };
-41
View File
@@ -1,41 +0,0 @@
import { User } from "@/components/user";
import { NostrAccount } from "@lume/system";
import { getBitcoinDisplayValues } from "@lume/utils";
import { useEffect, useMemo, useState } from "react";
export function Balance({ account }: { account: string }) {
const [balance, setBalance] = useState(0);
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => {
async function getBalance() {
const val = await NostrAccount.getBalance();
setBalance(val);
}
getBalance();
}, []);
return (
<div
data-tauri-drag-region
className="flex h-16 items-center justify-end px-3"
>
<div className="flex items-center gap-2">
<div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance
</div>
<div className="font-medium leading-tight">
{value.bitcoinFormatted}
</div>
</div>
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-9 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
);
}
+112 -48
View File
@@ -1,97 +1,112 @@
import { CancelIcon, CheckIcon } from "@lume/icons"; import { CheckIcon, HorizontalDotsIcon } from "@lume/icons";
import type { LumeColumn } from "@lume/types"; import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrent } from "@tauri-apps/api/webviewWindow"; import { listen } from "@tauri-apps/api/event";
import { useEffect, useRef, useState } from "react"; import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { memo, useCallback, useEffect, useRef, useState } from "react";
export function Column({ type WindowEvent = {
scroll: boolean;
resize: boolean;
};
export const Column = memo(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(() => {
if (!isCreated) return;
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 (isCreated) resizeWebview(); if (!container?.current) return;
}, [isResize]);
useEffect(() => {
if (isScroll && isCreated) repositionWebview();
}, [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}`;
// create new webview const prop = {
invoke("create_column", {
label: webviewLabel, label: webviewLabel,
x: rect.x, x: rect.x,
y: rect.y, y: rect.y,
width: rect.width, width: rect.width,
height: rect.height, height: rect.height,
url, url,
}).then(() => setIsCreated(true)); };
// create new webview
invoke("create_column", { column: prop }).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-[480px] shrink-0 p-2">
<div <div className="flex flex-col w-full h-full rounded-xl bg-black/5 dark:bg-white/10">
className={cn( <Header
"flex flex-col w-full h-full rounded-xl", label={column.label}
column.label !== "open" webview={webviewLabel}
? "bg-black/5 dark:bg-white/5 backdrop-blur-sm" name={column.name}
: "", />
)}
>
{column.label !== "open" ? (
<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>
); );
} });
function Header({ label, name }: { label: string; name: string }) { function Header({
label,
webview,
name,
}: { label: string; webview: string; name: string }) {
const [title, setTitle] = useState(name); const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false); const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => { const saveNewTitle = async () => {
const mainWindow = getCurrent(); const mainWindow = getCurrentWindow();
await mainWindow.emit("columns", { type: "set_title", label, title }); await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params // update search params
@@ -102,20 +117,69 @@ function Header({ label, name }: { label: string; name: string }) {
setIsChanged(false); setIsChanged(false);
}; };
const close = async () => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
const mainWindow = getCurrent(); e.preventDefault();
await mainWindow.emit("columns", { type: "remove", label });
}; const menuItems = await Promise.all([
MenuItem.new({
text: "Reload",
action: async () => {
await invoke("reload_column", { label: webview });
},
}),
MenuItem.new({
text: "Open in new window",
action: () => console.log("not implemented."),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Move left",
action: async () => {
await getCurrentWindow().emit("columns", {
type: "move",
label,
direction: "left",
});
},
}),
MenuItem.new({
text: "Move right",
action: async () => {
await getCurrentWindow().emit("columns", {
type: "move",
label,
direction: "right",
});
},
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "Close",
action: async () => {
await getCurrentWindow().emit("columns", {
type: "remove",
label,
});
},
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
useEffect(() => { useEffect(() => {
if (title.length !== name.length) setIsChanged(true); if (title.length !== name.length) setIsChanged(true);
}, [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-7">
<div className="relative flex gap-2 items-center"> <div className="relative flex items-center gap-2">
<div <div
contentEditable contentEditable
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
@@ -137,10 +201,10 @@ function Header({ label, name }: { label: string; name: string }) {
</div> </div>
<button <button
type="button" type="button"
onClick={() => close()} onClick={(e) => showContextMenu(e)}
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"
> >
<CancelIcon className="size-4" /> <HorizontalDotsIcon className="size-5" />
</button> </button>
</div> </div>
); );
+12 -18
View File
@@ -1,34 +1,28 @@
import { ThreadIcon } from "@lume/icons";
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { ThreadIcon } from "@lume/icons";
import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { LumeEvent } from "@lume/system"; import { memo, useMemo } from "react";
import { useMemo } from "react";
export function Conversation({ export const Conversation = memo(function Conversation({
event, event,
gossip,
className, className,
}: { }: {
event: NostrEvent; event: LumeEvent;
gossip?: boolean;
className?: string; className?: string;
}) { }) {
const thread = useMemo( const thread = useMemo(() => event.thread, [event]);
() => LumeEvent.getEventThread(event.tags, gossip),
[event],
);
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( 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", "bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<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" />
@@ -36,18 +30,18 @@ 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>
</Note.Provider> </Note.Provider>
); );
} });
@@ -1,24 +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.mentions;
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>
);
}
@@ -17,7 +17,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
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
? "rounded-full 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" ? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7", : "size-7",
)} )}
> >
@@ -1,24 +1,24 @@
import { QuoteIcon, RepostIcon } from "@lume/icons"; import { RepostIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Spinner } from "@lume/ui";
import { useNoteContext } from "../provider";
import { LumeWindow } from "@lume/system"; import { LumeWindow } from "@lume/system";
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";
export function NoteRepost({ large = false }: { large?: boolean }) { export function NoteRepost({ large = false }: { large?: boolean }) {
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 () => {
try {
if (isRepost) return; if (isRepost) return;
try {
setLoading(true); setLoading(true);
// repost // repost
@@ -27,74 +27,55 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
// 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>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={(e) => showContextMenu(e)}
className={cn( className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full", "inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large 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" ? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7", : "size-7",
)} )}
> >
{loading ? ( {loading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<RepostIcon <RepostIcon className={cn("size-4", isRepost ? "text-blue-500" : "")} />
className={cn("size-4", isRepost ? "text-blue-500" : "")}
/>
)} )}
{large ? "Repost" : null} {large ? "Repost" : null}
</button> </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={() => LumeWindow.openEditor(event.id, true)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<QuoteIcon className="size-4" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
); );
} }
@@ -1,23 +1,23 @@
import { ZapIcon } from "@lume/icons"; import { ZapIcon } from "@lume/icons";
import { LumeWindow } from "@lume/system";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router"; import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
import { cn } from "@lume/utils";
import { LumeWindow } from "@lume/system";
export function NoteZap({ large = false }: { large?: boolean }) { export function NoteZap({ large = false }: { large?: boolean }) {
const event = useNoteContext(); const event = useNoteContext();
const { settings } = useRouteContext({ strict: false }); const { settings } = useRouteContext({ strict: false });
if (!settings.zap) return null; if (!settings.display_zap_button) return null;
return ( return (
<button <button
type="button" type="button"
onClick={() => LumeWindow.openZap(event.id, event.pubkey)} onClick={() => LumeWindow.openZap(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
? "rounded-full 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" ? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7", : "size-7",
)} )}
> >
+11 -10
View File
@@ -2,32 +2,33 @@ 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" />
+57 -32
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,31 +21,27 @@ 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
const { content, hashtags, events, mentions } = event.meta;
// Define rich content
let richContent: ReactNode[] | string = settings.display_media
? content
: event.content;
for (const hashtag of hashtags) { for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g"); const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => { richContent = reactStringReplace(richContent, regex, (_, index) => {
return <Hashtag key={hashtag + index} tag={hashtag} />; return <Hashtag key={hashtag + index} tag={hashtag} />;
}); });
} }
}
if (events.length) {
for (const event of events) { for (const event of events) {
if (quote) { if (quote) {
richContent = reactStringReplace(richContent, event, (_, index) => ( richContent = reactStringReplace(richContent, event, (_, index) => (
@@ -56,9 +53,7 @@ export function NoteContent({
richContent = reactStringReplace(richContent, event, () => null); richContent = reactStringReplace(richContent, event, () => null);
} }
} }
}
if (mentions.length) {
for (const user of mentions) { for (const user of mentions) {
if (mention) { if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => ( richContent = reactStringReplace(richContent, user, (_, index) => (
@@ -70,7 +65,6 @@ export function NoteContent({
richContent = reactStringReplace(richContent, user, () => null); richContent = reactStringReplace(richContent, user, () => null);
} }
} }
}
richContent = reactStringReplace( richContent = reactStringReplace(
richContent, richContent,
@@ -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-lg">
<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 > 500
? "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
const { images, videos, hashtags, events, mentions } = event.meta;
// Define rich content
let richContent: ReactNode[] | string = event.content;
for (const hashtag of hashtags) { for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g"); const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => { richContent = reactStringReplace(richContent, regex, () => (
return <Hashtag key={nanoid()} tag={hashtag} />; <Hashtag key={nanoid()} tag={hashtag} />
});
}
}
if (events.length) {
for (const event of events) {
parsedContent = reactStringReplace(
parsedContent,
event,
(match, i) => <MentionNote key={match + i} eventId={event} />,
);
}
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
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(
"select-text leading-normal text-pretty content-break",
className,
)}
>
{content} {content}
</div> </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,8 +1,6 @@
import { LumeWindow, useEvent } from "@lume/system";
import { LinkIcon } from "@lume/icons";
import { useTranslation } from "react-i18next";
import { cn } from "@lume/utils";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { LinkIcon } from "@lume/icons";
import { LumeWindow, useEvent } from "@lume/system";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
export function MentionNote({ export function MentionNote({
@@ -12,31 +10,37 @@ export function MentionNote({
eventId: string; eventId: string;
openable?: boolean; openable?: boolean;
}) { }) {
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="py-2">
<div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
</div>
); );
} }
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="py-2">
{t("note.error")} <div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<p className="text-sm font-medium text-red-500">
Event not found with your current relay set
</p>
</div>
</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="py-2">
<div className="pl-4 py-3 flex flex-col w-full border-l-2 border-black/5 dark:border-white/5">
<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 h-8">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" /> <User.Avatar className="rounded-full size-6" />
<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
@@ -46,31 +50,27 @@ export function MentionNote({
</div> </div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
<div <div className="select-text text-pretty line-clamp-3 content-break leading-normal">
className={cn(
"px-3 select-text content-break whitespace-normal text-balance leading-normal",
data.content.length > 100 ? "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-start mt-3">
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
LumeWindow.openEvent(data); LumeWindow.openEvent(data);
}} }}
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-black/10 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400" className="inline-flex items-center gap-1 text-blue-500 text-sm"
> >
View post View post
<LinkIcon className="size-4" /> <LinkIcon className="size-3" />
</button> </button>
</div> </div>
) : ( ) : (
<div className="h-3" /> <div className="h-3" />
)} )}
</div> </div>
</div>
); );
} }
+43 -80
View File
@@ -1,99 +1,62 @@
import { HorizontalDotsIcon } from "@lume/icons"; import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
import { LumeWindow } from "@lume/system"; import { useCallback } from "react";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
export function NoteMenu() { export function NoteMenu() {
const { t } = useTranslation();
const event = useNoteContext(); const event = useNoteContext();
const copyID = async () => { const showContextMenu = useCallback(async (e: React.MouseEvent) => {
await writeText(await event.idAsBech32()); e.preventDefault();
};
const copyRaw = async () => { const menuItems = await Promise.all([
await writeText(JSON.stringify(event)); MenuItem.new({
}; 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 copyNpub = async () => { const menu = await Menu.new({
await writeText(await event.pubkeyAsBech32()); items: menuItems,
}; });
const copyLink = async () => { await menu.popup().catch((e) => console.error(e));
await writeText(`https://njump.me/${await event.idAsBech32()}`); }, []);
};
return ( return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button <button
type="button" type="button"
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400" onClick={(e) => showContextMenu(e)}
className="inline-flex items-center justify-center group size-7 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={() => LumeWindow.openEvent(event)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewThread")}
</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={() => LumeWindow.openProfile(event.pubkey)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewAuthor")}
</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-[400px] max-w-[400px] h-auto 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,176 @@
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 await window.setFocus(); 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 () => {
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 px-3 overflow-hidden group">
items={urls} <div ref={emblaRef} className="w-full h-[320px]">
renderItem={({ item, isSnapPoint }) => ( <div className="flex w-full gap-2 scrollbar-none">
<CarouselItem key={item} isSnapPoint={isSnapPoint}> {imageUrls.map((url, index) => (
<LazyImage
/* biome-ignore lint/suspicious/noArrayIndexKey: url can be duplicated */
key={url + index}
url={url}
inView={slidesInView.indexOf(index) > -1}
/>
))}
</div>
</div>
<div 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 <img
src={item} src={
alt={item} inView
? url
: "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
}
data-src={url}
alt={url}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: "auto" }} style={{ contentVisibility: "auto" }}
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" className="object-cover w-full h-full rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(item)} onClick={() => open(url)}
/> onKeyDown={() => open(url)}
</CarouselItem> 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,12 +1,30 @@
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 ( return (
<div className="my-1 overflow-hidden rounded-xl"> <a
href={url}
target="_blank"
rel="noreferrer"
className="inline text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<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"
preload="metadata"
controls controls
muted muted
> >
<source src={url} type="video/mp4" /> <source src={`${url}#t=0.1`} type="video/mp4" />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</div> </div>
@@ -1,36 +1,18 @@
import { Carousel, CarouselItem } from "@lume/ui";
export function Videos({ urls }: { urls: string[] }) { export function Videos({ urls }: { urls: string[] }) {
if (urls.length === 1) {
return ( return (
<div className="group px-3"> <div className="group px-3">
{urls.map((url) => (
<video <video
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15" key={url}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
preload="metadata"
controls controls
muted muted
> >
<source src={urls[0]} type="video/mp4" /> <source src={`${urls[0]}#t=0.1`} type="video/mp4" />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
))}
</div> </div>
); );
} }
return (
<Carousel
items={urls}
renderItem={({ item, isSnapPoint }) => (
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
<video
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls={false}
muted
>
<source src={item} type="video/mp4" />
Your browser does not support the video tag.
</video>
</CarouselItem>
)}
/>
);
}
@@ -1,5 +1,4 @@
import { LumeEvent } from "@lume/system"; import type { LumeEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react"; import { type ReactNode, createContext, useContext } from "react";
const NoteContext = createContext<LumeEvent>(null); const NoteContext = createContext<LumeEvent>(null);
@@ -8,14 +7,10 @@ export function NoteProvider({
event, event,
children, children,
}: { }: {
event: NostrEvent; event: LumeEvent;
children: ReactNode; children: ReactNode;
}) { }) {
const lumeEvent = new LumeEvent(event); return <NoteContext.Provider value={event}>{children}</NoteContext.Provider>;
return (
<NoteContext.Provider value={lumeEvent}>{children}</NoteContext.Provider>
);
} }
export function useNoteContext() { export function useNoteContext() {
+37 -36
View File
@@ -1,23 +1,50 @@
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 { 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";
import { LumeWindow } from "@lume/system";
export function NoteUser({ className }: { className?: string }) { export function NoteUser({ className }: { className?: string }) {
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
className={cn("flex items-start justify-between", className)}
>
<div className="flex w-full gap-2"> <div className="flex w-full gap-2">
<HoverCard.Trigger className="shrink-0"> <button
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" /> type="button"
</HoverCard.Trigger> onClick={(e) => showContextMenu(e)}
<div className="flex w-full items-center gap-3"> className="shrink-0"
>
<User.Avatar className="rounded-full size-8" />
</button>
<div className="flex items-center w-full gap-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" /> <User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 /> <User.NIP05 />
@@ -30,32 +57,6 @@ export function NoteUser({ className }: { className?: string }) {
</div> </div>
</div> </div>
</User.Root> </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="size-11 rounded-lg object-cover" />
<div className="flex flex-col gap-2">
<div className="inline-flex items-center gap-1">
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
<User.NIP05 />
</div>
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
<button
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
View profile
</button>
</div>
</div>
<HoverCard.Arrow className="fill-black dark:fill-white" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
</User.Provider> </User.Provider>
); );
} }
@@ -1,32 +0,0 @@
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
import { cn } from "@lume/utils";
export function Notification({
event,
className,
}: {
event: NostrEvent;
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>
);
}
+10 -13
View File
@@ -1,29 +1,26 @@
import { QuoteIcon } from "@lume/icons";
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import { QuoteIcon } from "@lume/icons";
import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { memo } from "react";
export function Quote({ export const Quote = memo(function Quote({
event, event,
className, className,
}: { }: {
event: NostrEvent; 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
className={cn( 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", "bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
className, className,
)} )}
> >
<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,16 +29,16 @@ 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>
</Note.Provider> </Note.Provider>
); );
} });
+34 -36
View File
@@ -1,84 +1,82 @@
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 { type LumeEvent, NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { NostrEvent } from "@lume/types"; import { memo } from "react";
import { NostrQuery } from "@lume/system";
export function RepostNote({ export const RepostNote = memo(function RepostNote({
event, event,
className, className,
}: { }: {
event: NostrEvent; event: LumeEvent;
className?: string; className?: string;
}) { }) {
const { const { isLoading, isError, data } = useQuery({
isLoading, queryKey: ["event", event.repostId],
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: NostrEvent = JSON.parse(event.content); return data;
return embed;
}
const id = event.tags.find((el) => el[0] === "e")?.[1];
const repostEvent = await NostrQuery.getEvent(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 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 />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Loading event... 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">
<div className="inline-flex items-center gap-3">
<Note.Open /> <Note.Open />
<Note.Reply /> <Note.Reply />
<Note.Repost /> <Note.Repost />
<Note.Zap /> <Note.Zap />
</div> </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="rounded-full size-6" />
</User.Root>
</User.Provider>
</div>
</div>
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
)} )}
</Note.Root> </Note.Root>
); );
} });
+9 -8
View File
@@ -1,28 +1,29 @@
import type { NostrEvent } from "@lume/types";
import { cn } from "@lume/utils";
import { Note } from "@/components/note"; import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import { cn } from "@lume/utils";
import { memo } from "react";
export function TextNote({ export const TextNote = memo(function TextNote({
event, event,
className, className,
}: { }: {
event: NostrEvent; event: LumeEvent;
className?: string; className?: string;
}) { }) {
return ( return (
<Note.Provider event={event}> <Note.Provider event={event}>
<Note.Root <Note.Root
className={cn( className={cn(
"bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50", "bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5",
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,4 +32,4 @@ export function TextNote({
</Note.Root> </Note.Root>
</Note.Provider> </Note.Provider>
); );
} });
+53 -14
View File
@@ -1,35 +1,74 @@
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 { useMemo } from "react"; import { useMemo } from "react";
import { useUserContext } from "./provider"; 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 fallbackAvatar = useMemo( 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 fallback = useMemo(
() => () =>
`data:image/svg+xml;utf8,${encodeURIComponent( `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50), minidenticon(user.pubkey, 60, 50),
)}`, )}`,
[user], [user.pubkey],
); );
if (settings && !settings.display_avatar) {
return ( return (
<Avatar.Root className="shrink-0"> <Avatar.Root
<Avatar.Image className={cn(
src={user.profile?.picture} "shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
alt={user.pubkey} className,
loading="eager" )}
decoding="async" >
className={cn("outline-[.5px] outline-black/5", className)}
/>
<Avatar.Fallback delayMs={120}> <Avatar.Fallback delayMs={120}>
<img <img
src={fallbackAvatar} src={fallback}
alt={user.pubkey} alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)} loading="lazy"
decoding="async"
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
return (
<Avatar.Root
className={cn(
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
className,
)}
>
<Avatar.Image
src={picture}
alt={user.pubkey}
loading="lazy"
decoding="async"
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
<Avatar.Fallback>
<img
src={fallback}
alt={user.pubkey}
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/> />
</Avatar.Fallback> </Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
+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,6 +1,5 @@
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
import { NostrAccount } from "@lume/system"; import { NostrAccount } from "@lume/system";
@@ -14,34 +13,30 @@ export function UserFollowButton({
}) { }) {
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 NostrAccount.follow(user.pubkey, user.profile?.name); const toggle = await NostrAccount.toggleContact(user.pubkey);
if (add) setFollowed(true);
} else { if (toggle) {
const remove = await NostrAccount.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 NostrAccount.getContactList(); 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 (
@@ -55,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>
); );
+14 -1
View File
@@ -4,19 +4,32 @@ import * as Tooltip from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useUserContext } from "./provider"; import { useUserContext } from "./provider";
import { NostrQuery } from "@lume/system"; 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 { 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?.length) return false;
const verify = await NostrQuery.verifyNip05( const verify = await NostrQuery.verifyNip05(
user.pubkey, user.pubkey,
user.profile?.nip05, user.profile?.nip05,
); );
return verify; return verify;
}, },
enabled: !!user.profile?.nip05, 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>
+134 -95
View File
@@ -1,19 +1,19 @@
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, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system"; import { NostrQuery } from "@lume/system";
import type { EventColumns, LumeColumn } from "@lume/types"; 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 { getCurrent } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
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 () => { loader: async () => {
const columns = NostrQuery.getColumns(); const columns = await NostrQuery.getColumns();
return columns; return columns;
}, },
component: Screen, component: Screen,
@@ -22,64 +22,65 @@ export const Route = createFileRoute("/$account/home")({
function Screen() { function Screen() {
const { account } = Route.useParams(); const { account } = Route.useParams();
const initialColumnList = Route.useLoaderData(); const initialColumnList = Route.useLoaderData();
const vlistRef = useRef<VListHandle>(null);
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 prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, {
align: "center",
}); });
};
const goRight = () => { const scrollPrev = useCallback(() => {
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1); if (emblaApi) emblaApi.scrollPrev();
setSelectedIndex(nextIndex); }, [emblaApi]);
vlistRef.current.scrollToIndex(nextIndex, {
align: "center", const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const emitScrollEvent = useCallback(() => {
getCurrentWindow().emit("child_webview", { scroll: true });
}, []);
const emitResizeEvent = useCallback(() => {
getCurrentWindow().emit("child_webview", { resize: true, direction: "x" });
}, []);
const openLumeStore = useCallback(async () => {
await getCurrentWindow().emit("columns", {
type: "add",
column: {
label: "store",
name: "Column Gallery",
content: "/store",
},
}); });
}; }, []);
const add = useDebouncedCallback((column: LumeColumn) => { const add = useDebouncedCallback((column: LumeColumn) => {
// update col label column.label = `${column.label}-${nanoid()}`; // update col label
column.label = `${column.label}-${nanoid()}`; setColumns((prev) => [column, ...prev]);
// create new cols
const cols = [...columns];
const openColIndex = cols.findIndex((col) => col.label === "open");
const newCols = [
...cols.slice(0, openColIndex),
column,
...cols.slice(openColIndex),
];
setColumns(newCols);
setSelectedIndex(newCols.length);
setIsScroll(true);
// scroll to the newest column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "end",
});
}, 150); }, 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));
}, 150);
const move = useDebouncedCallback(
(label: string, direction: "left" | "right") => {
const newCols = [...columns];
const col = newCols.find((el) => el.label === label);
const colIndex = newCols.findIndex((el) => el.label === label);
newCols.splice(colIndex, 1);
if (direction === "left") newCols.splice(colIndex - 1, 0, col);
if (direction === "right") newCols.splice(colIndex + 1, 0, col);
setColumns(newCols); setColumns(newCols);
setSelectedIndex(newCols.length); },
setIsScroll(true); 150,
);
// scroll to the first column
vlistRef.current.scrollToIndex(newCols.length - 1, {
align: "start",
});
}, 150);
const updateName = useDebouncedCallback((label: string, title: string) => { const updateName = useDebouncedCallback((label: string, title: string) => {
const currentColIndex = columns.findIndex((col) => col.label === label); const currentColIndex = columns.findIndex((col) => col.label === label);
@@ -93,77 +94,115 @@ 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();
break;
case "ArrowRight":
if (emblaApi) emblaApi.scrollNext();
break;
default:
break;
}
event.preventDefault();
}, 150);
useEffect(() => {
if (emblaApi) {
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(() => {
if (columns?.length) {
NostrQuery.setColumns(columns).then(() => console.log("saved"));
}
}, [columns]);
useEffect(() => { useEffect(() => {
setColumns(initialColumnList); setColumns(initialColumnList);
}, [initialColumnList]); }, [initialColumnList]);
// Listen for keyboard event
useEffect(() => { useEffect(() => {
// save state window.addEventListener("keydown", handleKeyDown);
NostrQuery.setColumns(columns);
}, [columns]);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown]);
// Listen for columns event
useEffect(() => { useEffect(() => {
const unlistenColEvent = listen<EventColumns>("columns", (data) => { 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 === "move")
move(data.payload.label, data.payload.direction);
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}
itemSize={440}
overscan={5}
onScroll={() => setIsScroll(true)}
onScrollEnd={() => setIsScroll(false)}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
>
{columns.map((column) => (
<Column <Column
key={column.label} key={account + column.label}
column={column} column={column}
account={account} account={account}
isScroll={isScroll}
isResize={isResize}
/> />
))} ))}
</VList> <div className="shrink-0 p-2 h-full w-[480px]">
<Toolbar> <div className="size-full bg-black/5 dark:bg-white/5 rounded-xl flex items-center justify-center">
<div className="flex items-center gap-1">
<button <button
type="button" type="button"
onClick={() => goLeft()} 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 gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
> >
<ArrowLeftIcon className="size-5" /> <PlusIcon className="size-5" />
</button> Add Column
<button
type="button"
onClick={() => goRight()}
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"
>
<ArrowRightIcon className="size-5" />
</button> </button>
</div> </div>
</div>
</div>
</div>
<Toolbar>
<button
type="button"
onClick={() => scrollPrev()}
className="inline-flex items-center justify-center rounded-full size-8 hover:bg-black/5 dark:hover:bg-white/5"
>
<ArrowLeftIcon className="size-4" />
</button>
<button
type="button"
onClick={() => scrollNext()}
className="inline-flex items-center justify-center rounded-full size-8 hover:bg-black/5 dark:hover:bg-white/5"
>
<ArrowRightIcon className="size-4" />
</button>
</Toolbar> </Toolbar>
</div> </div>
); );
+173 -194
View File
@@ -1,254 +1,233 @@
import { User } from "@/components/user";
import { import {
BellIcon, ChevronDownIcon,
ComposeFilledIcon, ComposeFilledIcon,
HorizontalDotsIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
} from "@lume/icons"; } from "@lume/icons";
import { type NostrEvent, Kind } from "@lume/types";
import { User } from "@/components/user";
import {
cn,
decodeZapInvoice,
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 { LumeWindow, NostrAccount, NostrQuery } from "@lume/system"; import { LumeWindow, NostrAccount, NostrQuery } from "@lume/system";
import { Link } from "@tanstack/react-router"; import { cn } from "@lume/utils";
import { Outlet, createFileRoute } from "@tanstack/react-router";
type AccountSearch = { import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
accounts?: string[]; import { getCurrentWindow } from "@tauri-apps/api/window";
}; import { message } from "@tauri-apps/plugin-dialog";
import { memo, useCallback, useState } from "react";
export const Route = createFileRoute("/$account")({ export const Route = createFileRoute("/$account")({
validateSearch: (search: Record<string, unknown>): AccountSearch => { beforeLoad: async ({ params }) => {
return { const settings = await NostrQuery.getUserSettings();
accounts: (search?.accounts as string[]) || [], const accounts = await NostrAccount.getAccounts();
}; const otherAccounts = accounts.filter(
(account) => account !== params.account,
);
return { otherAccounts, settings };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { platform } = Route.useRouteContext(); const { settings, platform } = Route.useRouteContext();
const openLumeStore = async () => {
await getCurrentWindow().emit("columns", {
type: "add",
column: {
label: "store",
name: "Column Gallery",
content: "/store",
},
});
};
return ( return (
<div className="flex h-screen w-screen flex-col"> <div className="flex flex-col w-screen h-screen">
<div
data-tauri-drag-region
className="flex h-11 shrink-0 items-center justify-between px-3"
>
<div <div
data-tauri-drag-region data-tauri-drag-region
className={cn( className={cn(
"flex h-11 shrink-0 items-center justify-between pr-2", "flex-1 flex items-center gap-2",
platform === "macos" ? "ml-2 pl-20" : "pl-4", platform === "macos" ? "pl-[64px]" : "",
)} )}
> >
<div className="flex items-center gap-3"> <button
<Accounts /> type="button"
<Link onClick={() => openLumeStore()}
to="/landing/" className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
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" />
</Link> Column
</button>
<div id="toolbar" />
</div> </div>
<div className="flex items-center gap-2"> <div data-tauri-drag-region className="hidden md:flex md:flex-1">
<Search />
</div>
<div
data-tauri-drag-region
className="flex-1 flex items-center justify-end gap-3"
>
<button <button
type="button" type="button"
onClick={() => LumeWindow.openEditor()} 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 bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
> >
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
New Post New Post
</button> </button>
<Bell /> <Accounts />
<button </div>
type="button" </div>
onClick={() => LumeWindow.openSearch()} <div
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={cn(
"flex-1",
settings.vibrancy
? ""
: "bg-white dark:bg-black border-t border-black/20 dark:border-white/20",
)}
> >
<SearchIcon className="size-5" />
</button>
<div id="toolbar" />
</div>
</div>
<div className="flex-1">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
} }
function Accounts() { const Accounts = memo(function Accounts() {
const { accounts } = Route.useSearch(); const { otherAccounts } = Route.useRouteContext();
const { account } = Route.useParams(); const { account } = Route.useParams();
const [windowWidth, setWindowWidth] = useState<number>(null);
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const sortedList = useMemo(() => {
const list = accounts;
for (const [i, item] of list.entries()) { const showContextMenu = useCallback(
if (item === account) { async (e: React.MouseEvent) => {
list.splice(i, 1); e.preventDefault();
list.unshift(item);
}
}
return list; const menuItems = await Promise.all([
}, [accounts]); MenuItem.new({
text: "New Post",
action: () => LumeWindow.openEditor(),
}),
PredefinedMenuItem.new({ item: "Separator" }),
MenuItem.new({
text: "View Profile",
action: () => LumeWindow.openProfile(account),
}),
MenuItem.new({
text: "Open Settings",
action: () => LumeWindow.openSettings(),
}),
]);
const changeAccount = async (npub: string) => { const menu = await Menu.new({
if (npub === account) { items: menuItems,
return await LumeWindow.openProfile(account); });
}
// change current account and update signer await menu.popup().catch((e) => console.error(e));
},
[account],
);
const changeAccount = useCallback(
async (npub: string) => {
// Change current account and update signer
const select = await NostrAccount.loadAccount(npub); const select = await NostrAccount.loadAccount(npub);
if (select) { if (select) {
return navigate({ to: "/$account/home", params: { account: npub } }); // Reset current columns
await getCurrentWindow().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" });
}
};
const getWindowDimensions = () => {
const { innerWidth: width, innerHeight: height } = window;
return {
width,
height,
};
};
useEffect(() => {
function handleResize() {
setWindowWidth(getWindowDimensions().width);
}
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div data-tauri-drag-region className="flex items-center gap-3">
{sortedList
.slice(0, windowWidth > 500 ? account.length : 2)
.map((user) => (
<button key={user} type="button" onClick={() => changeAccount(user)}>
<User.Provider pubkey={user}>
<User.Root
className={cn(
"shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto",
user === account
? "ring-1 ring-teal-500 ring-offset-2 ring-offset-neutral-200 dark:ring-offset-neutral-950"
: "",
)}
>
<User.Avatar
className={cn(
"aspect-square h-auto rounded-full object-cover transition-all ease-in-out duration-150 will-change-auto",
user === account ? "w-7" : "w-8",
)}
/>
</User.Root>
</User.Provider>
</button>
))}
{accounts.length >= 3 && windowWidth <= 700 ? (
<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">
<HorizontalDotsIcon className="size-5" />
</Popover.Trigger>
<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">
{sortedList.slice(2).map((user) => (
<button
key={user}
type="button"
onClick={() => changeAccount(user)}
className="size-9 inline-flex items-center justify-center hover:bg-white/10 rounded-md"
>
<User.Provider pubkey={user}>
<User.Root className="rounded-full ring-1 ring-white/10">
<User.Avatar className="size-7 aspect-square h-auto rounded-full object-cover" />
</User.Root>
</User.Provider>
</button>
))}
<Popover.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
) : null}
</div>
);
}
function Bell() {
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: NostrEvent = JSON.parse(payload.payload);
const user = await NostrQuery.getProfile(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;
} }
}, },
[otherAccounts],
); );
return () => { return (
unlisten.then((f) => f()); <div data-tauri-drag-region className="hidden md:flex items-center gap-3">
}; {otherAccounts.map((npub) => (
<button key={npub} type="button" onClick={(e) => changeAccount(npub)}>
<User.Provider pubkey={npub}>
<User.Root className="shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto hover:ring-1 hover:ring-blue-500">
<User.Avatar className="rounded-full size-8" />
</User.Root>
</User.Provider>
</button>
))}
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="inline-flex items-center gap-1.5"
>
<User.Provider pubkey={account}>
<User.Root className="shrink-0 rounded-full">
<User.Avatar className="rounded-full size-8" />
</User.Root>
</User.Provider>
<ChevronDownIcon className="size-3" />
</button>
</div>
);
});
const Search = memo(function Search() {
const [searchType, setSearchType] = useState<"notes" | "users">("notes");
const [query, setQuery] = useState("");
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Notes",
action: () => setSearchType("notes"),
}),
MenuItem.new({
text: "Users",
action: () => setSearchType("users"),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []); }, []);
return ( return (
<div className="h-8 w-full px-3 text-sm rounded-full inline-flex items-center bg-black/5 dark:bg-white/5">
<button <button
type="button" type="button"
onClick={() => { onClick={(e) => showContextMenu(e)}
setCount(0); className="inline-flex items-center gap-1 capitalize text-sm font-medium pr-2 border-r border-black/10 dark:border-white/10 text-black/50 dark:text-white/50"
LumeWindow.openActivity(account);
}}
className="relative inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
> >
<BellIcon className="size-5" /> {searchType}
{count > 0 ? ( <ChevronDownIcon className="size-3" />
<span className="absolute right-0 top-0 block size-2 rounded-full bg-teal-500 ring-1 ring-black/5" />
) : null}
</button> </button>
); <input
type="text"
name="search"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
LumeWindow.openSearch(searchType, query);
} }
}}
className="h-full w-full px-3 text-sm rounded-full border-none ring-0 focus:ring-0 focus:outline-none bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50"
/>
<SearchIcon className="size-5" />
</div>
);
});
+4 -29
View File
@@ -1,46 +1,21 @@
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
import type { 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 { OsType } from "@tauri-apps/plugin-os";
import { Toaster } from "sonner";
interface RouterContext { interface RouterContext {
// System
queryClient: QueryClient; queryClient: QueryClient;
// App info platform: OsType;
platform?: Platform;
locale?: string;
// Settings
settings?: Settings;
// Accounts
accounts?: string[];
} }
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,
}); });
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";
import { NostrQuery } from "@lume/system";
export const Route = createFileRoute("/activity/$account/texts")({
loader: async ({ params }) => {
return { data: defer(NostrQuery.getUserActivities(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>
<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,67 +0,0 @@
import { User } from "@/components/user";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import { decodeZapInvoice } from "@lume/utils";
import { Await, createFileRoute, defer } from "@tanstack/react-router";
import { Suspense } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/activity/$account/zaps")({
loader: async ({ params }) => {
return {
data: defer(NostrQuery.getUserActivities(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>
);
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { Box, Container } from "@lume/ui"; import { Container } from "@lume/ui";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router"; import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/auth")({ export const Route = createLazyFileRoute("/auth")({
@@ -8,9 +8,9 @@ export const Route = createLazyFileRoute("/auth")({
function Screen() { function Screen() {
return ( return (
<Container withDrag> <Container withDrag>
<Box className="px-3 pt-3"> <div className="max-w-sm mx-auto size-full">
<Outlet /> <Outlet />
</Box> </div>
</Container> </Container>
); );
} }
@@ -5,37 +5,34 @@ 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 { toast } from "sonner";
export const Route = createFileRoute("/auth/new/$account/backup")({ export const Route = createFileRoute("/auth/$account/backup")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { account } = Route.useParams(); const { account } = Route.useParams();
const { t } = useTranslation(); const navigate = useNavigate();
const [key, setKey] = useState(null); const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState(""); const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false }); const [confirm, setConfirm] = useState({ c1: false, c2: false });
const navigate = useNavigate();
const submit = async () => { const submit = async () => {
try { try {
if (key) { if (key) {
if (!confirm.c1 || !confirm.c2 || !confirm.c3) { if (!confirm.c1 || !confirm.c2) {
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/$account/settings",
params: { account },
});
} }
// start loading // start loading
@@ -51,7 +48,10 @@ function Screen() {
}); });
} catch (e) { } catch (e) {
setLoading(false); setLoading(false);
toast.error(String(e)); await message(String(e), {
title: "Backup",
kind: "error",
});
} }
}; };
@@ -60,12 +60,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">
@@ -73,7 +76,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
@@ -84,7 +87,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>
@@ -100,12 +103,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>
@@ -120,7 +123,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">
@@ -131,7 +134,7 @@ function Screen() {
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1" htmlFor="confirm1"
> >
{t("backup.confirm1")} I will make sure keep it safe and not sharing with anyone.
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -140,7 +143,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">
@@ -151,27 +154,7 @@ function Screen() {
className="text-sm leading-none text-neutral-800 dark:text-neutral-200" className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2" htmlFor="confirm2"
> >
{t("backup.confirm2")} I understand I cannot recover private key.
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c3}
onCheckedChange={() =>
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"
id="confirm3"
>
<Checkbox.Indicator className="text-blue-500">
<CheckIcon className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm3"
>
{t("backup.confirm3")}
</label> </label>
</div> </div>
</div> </div>
@@ -183,9 +166,9 @@ 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 /> : "Continue"}
</button> </button>
</div> </div>
</div> </div>
@@ -1,204 +0,0 @@
import { LaurelIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system";
import { Spinner } from "@lume/ui";
import * as Switch from "@radix-ui/react-switch";
import { createFileRoute } from "@tanstack/react-router";
import { requestPermission } from "@tauri-apps/plugin-notification";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/$account/settings")({
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
component: Screen,
pendingComponent: Pending,
});
function Screen() {
const navigate = Route.useNavigate();
const { account } = Route.useParams();
const { settings } = Route.useRouteContext();
const { t } = useTranslation();
const [newSettings, setNewSettings] = useState(settings);
const [loading, setLoading] = useState(false);
const toggleNofitication = async () => {
await requestPermission();
setNewSettings((prev) => ({
...prev,
notification: !newSettings.notification,
}));
};
const toggleAutoUpdate = () => {
setNewSettings((prev) => ({
...prev,
autoUpdate: !newSettings.autoUpdate,
}));
};
const 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 NostrQuery.setSettings(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,139 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
import { useForm } from "react-hook-form";
export const Route = createFileRoute("/auth/create-profile")({
loader: async () => {
const account = await NostrAccount.createAccount();
return account;
},
component: Screen,
});
function Screen() {
const account = Route.useLoaderData();
const navigate = useNavigate();
const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>("");
const [loading, setLoading] = useState(false);
const onSubmit = async (data: {
name: string;
about: string;
website: string;
}) => {
setLoading(true);
try {
// Save account keys
const save = await NostrAccount.saveAccount(account.nsec);
// Then create profile
if (save) {
const profile: Metadata = { ...data, picture };
const eventId = await NostrAccount.createProfile(profile);
if (eventId) {
navigate({
to: "/auth/$account/backup",
params: { account: account.npub },
replace: true,
});
}
}
} catch (e) {
setLoading(false);
await message(String(e), { title: "Create Profile", kind: "error" });
}
};
return (
<div className="flex flex-col items-center justify-center size-full gap-4">
<div className="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="w-full mb-0">
<div className="flex flex-col gap-3 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
<div className="self-center relative rounded-full size-20 bg-neutral-200 dark:bg-white/70 my-3">
{picture ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
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" />
</AvatarUploader>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium">
Display Name *
</label>
<input
type={"text"}
{...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland"
spellCheck={false}
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 className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
Name
</label>
<input
type={"text"}
{...register("name")}
placeholder="e.g. alice"
spellCheck={false}
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 className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium">
Bio
</label>
<textarea
{...register("about")}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium">
Website
</label>
<input
type="url"
{...register("website")}
placeholder="e.g. https://alice.me"
spellCheck={false}
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
type="submit"
disabled={loading}
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Continue"}
</button>
</form>
</div>
);
}
@@ -0,0 +1,90 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createLazyFileRoute("/auth/import")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const [key, setKey] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!key.startsWith("nsec1")) {
return await message(
"You need to enter a valid private key starts with nsec or ncryptsec",
{ title: "Import Key", kind: "info" },
);
}
try {
setLoading(true);
const npub = await NostrAccount.saveAccount(key, password);
if (npub) {
navigate({ to: "/", replace: true });
}
} catch (e) {
setLoading(false);
await message(String(e), { title: "Import Key", kind: "error" });
}
};
return (
<div className="flex flex-col items-center justify-center size-full gap-4">
<div className="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div>
<div className="flex flex-col w-full">
<div className="flex flex-col gap-3 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
onChange={(e) => setKey(e.target.value)}
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 className="flex flex-col gap-1">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
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
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex items-center justify-center w-full h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Login"}
</button>
</div>
</div>
);
}
@@ -1,144 +0,0 @@
import { AvatarUploader } from "@/components/avatarUploader";
import { PlusIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import type { Metadata } from "@lume/types";
import { Spinner } from "@lume/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/new/profile")({
component: Screen,
loader: async () => {
const account = await NostrAccount.createAccount();
return account;
},
});
function Screen() {
const account = Route.useLoaderData();
const navigate = useNavigate();
const { t } = useTranslation();
const { register, handleSubmit } = useForm();
const [picture, setPicture] = useState<string>("");
const [loading, setLoading] = useState(false);
const onSubmit = async (data: {
name: string;
about: string;
website: string;
}) => {
setLoading(true);
try {
// Save account keys
const save = await NostrAccount.saveAccount(account.nsec);
// Then create profile
if (save) {
const profile: Metadata = { ...data, picture };
const eventId = await NostrAccount.createProfile(profile);
if (eventId) {
navigate({
to: "/auth/new/$account/backup",
params: { account: account.npub },
replace: true,
});
}
}
} catch (e) {
setLoading(false);
toast.error(String(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="text-center">
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
</div>
<div>
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{picture ? (
<img
src={picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
/>
) : null}
<AvatarUploader
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"
>
<PlusIcon className="size-8" />
</AvatarUploader>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col gap-3"
>
<div className="flex flex-col gap-1">
<label htmlFor="display_name" className="font-medium">
{t("user.displayName")} *
</label>
<input
type={"text"}
{...register("display_name", { required: true, minLength: 1 })}
placeholder="e.g. Alice in Nostrland"
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"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium">
{t("user.name")}
</label>
<input
type={"text"}
{...register("name")}
placeholder="e.g. alice"
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"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium">
{t("user.bio")}
</label>
<textarea
{...register("about")}
placeholder="e.g. Artist, anime-lover, and k-pop fan"
spellCheck={false}
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium">
{t("user.website")}
</label>
<input
type="url"
{...register("website")}
placeholder="e.g. https://alice.me"
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"
/>
</div>
<button
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"
>
{loading ? <Spinner /> : t("global.continue")}
</button>
</form>
</div>
);
}
@@ -1,90 +0,0 @@
import { NostrAccount } from "@lume/system";
import { Spinner } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createLazyFileRoute("/auth/privkey")({
component: Screen,
});
function Screen() {
const navigate = Route.useNavigate();
const [key, setKey] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!key.startsWith("nsec1"))
return toast.warning(
"You need to enter a valid private key starts with nsec or ncryptsec",
);
try {
setLoading(true);
const npub = await NostrAccount.saveAccount(key, password);
if (npub) {
navigate({
to: "/auth/$account/settings",
params: { account: npub },
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="text-center">
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
</div>
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1">
<label
htmlFor="key"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Private Key
</label>
<input
name="key"
type="text"
placeholder="nsec or ncryptsec..."
value={key}
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"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="password"
className="font-medium text-neutral-900 dark:text-neutral-100"
>
Password (Optional)
</label>
<input
name="password"
type="password"
value={password}
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"
/>
</div>
<button
type="button"
onClick={() => submit()}
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"
>
{loading ? <Spinner /> : "Login"}
</button>
</div>
</div>
);
}
+16 -18
View File
@@ -1,8 +1,8 @@
import { NostrAccount } from "@lume/system"; 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,
@@ -15,36 +15,34 @@ function Screen() {
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 NostrAccount.connectRemoteAccount(uri); const remoteAccount = await NostrAccount.connectRemoteAccount(uri);
if (npub) { if (remoteAccount?.length) {
navigate({ navigate({ to: "/", replace: true });
to: "/auth/$account/settings",
params: { 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 size-full gap-4">
<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">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 w-full p-3 overflow-hidden bg-white rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
<label <label
htmlFor="uri" htmlFor="uri"
className="font-medium text-neutral-900 dark:text-neutral-100" className="font-medium text-neutral-900 dark:text-neutral-100"
@@ -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 h-9 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg 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}
@@ -0,0 +1,150 @@
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 {
if (!data.url.startsWith("wss://") || !data.url.startsWith("ws://")) {
return await message("Relay must be starts with wss:// or ws://", {
title: "Bootstrap Relays",
kind: "info",
});
}
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
data-tauri-drag-region
className="relative flex flex-col items-center justify-between w-full h-full"
>
<div
data-tauri-drag-region
className="absolute top-0 left-0 h-14 w-full"
/>
<div className="flex items-end justify-center flex-1 w-full px-4 pb-4">
<div className="text-center">
<h2 className="text-xl font-semibold">Customize Bootstrap Relays</h2>
</div>
</div>
<div className="flex flex-col items-center flex-1 w-full">
<div className="flex flex-col w-full max-w-sm mx-auto p-3 overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary dark:bg-white/10 dark:ring-1 ring-white/15">
{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>
<div className="w-full max-w-sm mx-auto">
<button
type="button"
onClick={() => save()}
disabled={isLoading}
className="inline-flex items-center justify-center w-full h-9 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>
<div className="flex-1" />
</div>
);
}
+22 -22
View File
@@ -1,11 +1,11 @@
import { User } from "@/components/user";
import { CancelIcon, PlusIcon } from "@lume/icons"; 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";
import { NostrAccount, NostrQuery } from "@lume/system";
export const Route = createFileRoute("/create-group")({ export const Route = createFileRoute("/create-group")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
@@ -65,25 +65,25 @@ function Screen() {
} }
} 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>
@@ -92,23 +92,23 @@ 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 rounded-xl">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
name="npub" name="npub"
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>
@@ -122,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 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="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>
@@ -138,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>
)} )}
@@ -153,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 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="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>
@@ -166,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
@@ -187,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>
@@ -2,8 +2,8 @@ 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 { 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,8 +24,12 @@ 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);
@@ -37,13 +41,16 @@ function Screen() {
} }
} 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 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,11 +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 { toast } from "sonner";
import type { ColumnRouteSearch } from "@lume/types";
import { NostrAccount } from "@lume/system"; import { NostrAccount } from "@lume/system";
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 => {
@@ -59,16 +59,19 @@ function Screen() {
} }
} 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 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/20shadow-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="rounded-full size-7" />
<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>
+22 -13
View File
@@ -1,11 +1,16 @@
import { CheckCircleIcon } from "@lume/icons"; import { CheckCircleIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system"; import { NostrQuery } from "@lume/system";
import type { ColumnRouteSearch, Topic } from "@lume/types"; 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 => {
@@ -48,38 +53,42 @@ function Screen() {
} }
} 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 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 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>
@@ -95,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,15 +1,14 @@
import { AddMediaIcon } from "@lume/icons"; import { AddMediaIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system"; 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 type { UnlistenFn } from "@tauri-apps/api/event"; import type { UnlistenFn } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/api/window"; import { getCurrentWindow } 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 [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -25,7 +24,7 @@ export function MediaButton({ className }: { className?: string }) {
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" });
} }
}; };
@@ -33,7 +32,7 @@ export function MediaButton({ className }: { className?: string }) {
let unlisten: UnlistenFn = undefined; let unlisten: UnlistenFn = undefined;
async function listenFileDrop() { async function listenFileDrop() {
const window = getCurrent(); const window = getCurrentWindow();
if (!unlisten) { if (!unlisten) {
unlisten = await window.listen("tauri://file-drop", async (event) => { unlisten = await window.listen("tauri://file-drop", async (event) => {
// @ts-ignore, lfg !!! // @ts-ignore, lfg !!!
@@ -61,29 +60,18 @@ export function MediaButton({ className }: { className?: string }) {
}, []); }, []);
return ( return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => upload()} onClick={() => upload()}
disabled={loading} disabled={loading}
className={cn("inline-flex items-center justify-center", className)} 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"
> >
{loading ? ( {loading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<AddMediaIcon className="size-4" /> <AddMediaIcon className="size-4" />
)} )}
Add media
</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,88 +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 { useSlateStatic } from "slate-react";
import type { Contact } from "@lume/types";
import { toast } from "sonner";
import { User } from "@/components/user";
import { NostrAccount, NostrQuery } from "@lume/system";
export function MentionButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const [contacts, setContacts] = useState<string[]>([]);
const select = async (user: string) => {
try {
const metadata = await NostrQuery.getProfile(user);
const contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact);
} catch (e) {
toast.error(String(e));
}
};
useEffect(() => {
async function getContacts() {
const data = await NostrAccount.getContactList();
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.length < 1 ? (
<div className="w-full h-full flex items-center justify-center">
<p className="text-sm text-white">Contact List is empty.</p>
</div>
) : (
contacts.map((contact) => (
<DropdownMenu.Item
key={contact}
onClick={() => select(contact)}
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
>
<User.Provider pubkey={contact}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="shrink-0 size-8 rounded-full" />
<User.Name className="text-sm font-medium text-white dark:text-black" />
</User.Root>
</User.Provider>
</DropdownMenu.Item>
))
)}
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
@@ -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>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => setPow((prev) => !prev)} onClick={() =>
className={cn( setDifficulty((prev) => ({ ...prev, enable: !prev.enable }))
"inline-flex items-center justify-center", }
className, 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"
pow ? "bg-blue-500 text-white" : "",
)}
> >
<NsfwIcon className="size-4" /> <PowIcon className="size-4" />
PoW
</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">
Proof of Work
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
); );
} }
@@ -1,40 +1,19 @@
import { NsfwIcon } from "@lume/icons"; import { NsfwIcon } 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 WarningToggle({ export function WarningButton({
warning,
setWarning, setWarning,
className,
}: { }: {
warning: boolean; setWarning: Dispatch<SetStateAction<{ enable: boolean; reason: string }>>;
setWarning: Dispatch<SetStateAction<boolean>>;
className?: string;
}) { }) {
return ( return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button <button
type="button" type="button"
onClick={() => setWarning((prev) => !prev)} onClick={() => setWarning((prev) => ({ ...prev, enable: !prev.enable }))}
className={cn( 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"
"inline-flex items-center justify-center",
className,
warning ? "bg-blue-500 text-white" : "",
)}
> >
<NsfwIcon className="size-4" /> <NsfwIcon className="size-4" />
Mark as sensitive
</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">
Mark as sensitive content
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
); );
} }
+150 -72
View File
@@ -1,15 +1,13 @@
import { Note } from "@/components/note";
import { MentionNote } from "@/components/note/mentions/note";
import { User } from "@/components/user";
import { ComposeFilledIcon } from "@lume/icons"; import { ComposeFilledIcon } from "@lume/icons";
import { LumeEvent, useEvent } from "@lume/system";
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 { useState } from "react"; import { nip19 } from "nostr-tools";
import { useTranslation } from "react-i18next"; import { useEffect, useState } from "react";
import { type Descendant, Node, Transforms, createEditor } from "slate"; import { type Descendant, Node, Transforms, createEditor } from "slate";
import { import {
Editable, Editable,
@@ -21,14 +19,12 @@ import {
withReact, withReact,
} from "slate-react"; } from "slate-react";
import { MediaButton } from "./-components/media"; import { MediaButton } from "./-components/media";
import { MentionButton } from "./-components/mention"; import { PowButton } from "./-components/pow";
import { LumeEvent } from "@lume/system"; import { WarningButton } from "./-components/warning";
import { WarningToggle } from "./-components/warning";
import { MentionNote } from "@/components/note/mentions/note";
type EditorSearch = { type EditorSearch = {
reply_to: string; reply_to: string;
quote: boolean; quote: string;
}; };
type EditorElement = { type EditorElement = {
@@ -41,26 +37,47 @@ 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,
}; };
}, },
component: Screen, beforeLoad: ({ search }) => {
}); let initialValue: EditorElement[];
const initialValue: EditorElement[] = [ if (search?.quote?.length) {
const eventId = nip19.noteEncode(search.quote);
initialValue = [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${eventId}`,
children: [{ text: "" }],
},
];
} else {
initialValue = [
{ {
type: "paragraph", type: "paragraph",
children: [{ text: "" }], children: [{ text: "" }],
}, },
]; ];
}
return { initialValue };
},
component: Screen,
});
function Screen() { function Screen() {
const search = Route.useSearch(); const { reply_to } = Route.useSearch();
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 [warning, setWarning] = 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())))),
); );
@@ -103,63 +120,40 @@ function Screen() {
const content = serialize(editor.children); const content = serialize(editor.children);
const eventId = await LumeEvent.publish( const eventId = await LumeEvent.publish(
content, content,
search.reply_to, warning.enable && warning.reason.length ? warning.reason : null,
search.quote, difficulty.enable && difficulty.num > 0 ? difficulty.num : null,
warning, reply_to,
); );
if (eventId) { if (eventId) {
await sendNativeNotification(
"Your note has been published successfully.",
"Lume",
);
}
// stop loading // stop loading
setLoading(false); setLoading(false);
// reset form // reset form
reset(); 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 flex flex-col"> <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="shrink-0 flex h-14 w-full 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">
<WarningToggle <div className="text-sm font-semibold shrink-0">Reply to:</div>
warning={warning} <ChildNote id={reply_to} />
setWarning={setWarning}
className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
/>
<MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<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-1 overflow-y-auto flex flex-col">
{search.reply_to ? (
<div className="px-4 py-2">
<MentionNote eventId={search.reply_to} />
</div> </div>
) : null} ) : null}
<div className="overflow-y-auto scrollbar-none 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}
@@ -168,17 +162,103 @@ function Screen() {
spellCheck={false} spellCheck={false}
renderElement={(props) => <Element {...props} />} renderElement={(props) => <Element {...props} />}
placeholder={ placeholder={
search.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" />
</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;
@@ -190,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);
@@ -259,6 +339,7 @@ const Image = ({ attributes, element, children }) => {
selected && focused ? "ring-blue-500" : "ring-transparent", selected && focused ? "ring-blue-500" : "ring-transparent",
)} )}
onClick={() => Transforms.removeNodes(editor, { at: path })} onClick={() => Transforms.removeNodes(editor, { at: path })}
onKeyDown={() => Transforms.removeNodes(editor, { at: path })}
/> />
</div> </div>
); );
@@ -274,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>
); );
}; };
@@ -286,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>
); );
@@ -1,70 +0,0 @@
import { NostrQuery, useEvent } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { Box, Container, Spinner } from "@lume/ui";
import { Note } from "@/components/note";
import { createFileRoute } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { ReplyList } from "./-components/replyList";
export const Route = createFileRoute("/events/$eventId")({
beforeLoad: async () => {
const settings = await NostrQuery.getSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { eventId } = Route.useParams();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
);
}
if (isError) {
<div className="flex h-full w-full items-center justify-center">
<p>Not found.</p>
</div>;
}
return (
<Container withDrag>
<Box className="scrollbar-none">
<WindowVirtualizer>
<MainNote data={data} />
{data ? (
<ReplyList eventId={eventId} />
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="size-5" />
</div>
)}
</WindowVirtualizer>
</Box>
</Container>
);
}
function MainNote({ data }: { data: NostrEvent }) {
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>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { Note } from "@/components/note";
import { LumeEvent, NostrQuery } from "@lume/system";
import type { Meta } from "@lume/types";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useRef, useState } from "react";
import { Virtualizer } from "virtua";
import NoteParent from "./-components/parent";
type Payload = {
raw: string;
parsed: Meta;
};
export const Route = createFileRoute("/events/$id")({
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
loader: async ({ params }) => {
const event = await NostrQuery.getEvent(params.id);
return event;
},
component: Screen,
});
function Screen() {
const ref = useRef<HTMLDivElement>(null);
return (
<div className="h-full flex flex-col">
<div
data-tauri-drag-region
className="shrink-0 h-8 w-full border-b border-black/5 dark:border-white/5"
/>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex-1"
>
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<RootEvent />
<Virtualizer scrollRef={ref}>
<ReplyList />
</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>
</div>
);
}
function RootEvent() {
const event = Route.useLoaderData();
return (
<Note.Provider event={event}>
<Note.Root className="bg-white dark:bg-black/10 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="flex items-center gap-2 px-3 mt-6 h-12 rounded-b-xl bg-neutral-50 dark:bg-white/5">
<Note.Reply large />
<Note.Repost large />
<Note.Zap large />
</div>
</Note.Root>
</Note.Provider>
);
}
function ReplyList() {
const event = Route.useLoaderData();
const [replies, setReplies] = useState<LumeEvent[]>([]);
useEffect(() => {
const unlistenEvent = getCurrentWindow().listen<Payload>(
"new_reply",
(data) => {
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
setReplies((prev) => [event, ...prev]);
},
);
const unlistenWindow = getCurrentWindow().onCloseRequested(async () => {
await event.unlistenEventReply();
await getCurrentWindow().destroy();
});
return () => {
unlistenEvent.then((f) => f());
unlistenWindow.then((f) => f());
};
}, []);
useEffect(() => {
let mounted = true;
async function getReplies() {
const data = await event.getEventReplies();
if (mounted) {
setReplies(data);
// Start listen for new reply
event.listenEventReply();
}
}
getReplies();
return () => {
mounted = false;
};
}, []);
return (
<div>
<div className="flex items-center text-sm font-semibold h-14 text-neutral-600 dark:text-white/30">
All replies
</div>
<div className="flex flex-col gap-3">
{!replies.length ? (
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center gap-2 py-4">
<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) => <NoteParent key={event.id} event={event} />)
)}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import NoteParent from "./parent";
import { memo } from "react";
const NoteChild = memo(function NoteChild({ event }: { event: LumeEvent }) {
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col gap-6 mb-3">
<div>
<div className="flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<div className="flex gap-2">
<div className="w-8 shrink-0" />
<div className="flex-1 flex flex-col gap-2">
<Note.ContentLarge />
<div className="flex items-center gap-1">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
{event.replies?.length ? (
<div className="flex flex-col gap-3 pl-4">
<div className="flex flex-col pl-6 border-l border-black/10 dark:border-white/10">
{event.replies?.map((childEvent) => (
<NoteParent key={childEvent.id} event={childEvent} />
))}
</div>
</div>
) : null}
</Note.Root>
</Note.Provider>
);
});
export default NoteChild;
@@ -0,0 +1,41 @@
import { Note } from "@/components/note";
import type { LumeEvent } from "@lume/system";
import NoteChild from "./child";
import { memo } from "react";
const NoteParent = memo(function NoteParent({ event }: { event: LumeEvent }) {
return (
<Note.Provider event={event}>
<Note.Root className="flex flex-col gap-6 mb-3">
<div>
<div className="flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<div className="flex gap-2">
<div className="w-8 shrink-0" />
<div className="flex-1 flex flex-col gap-2">
<Note.ContentLarge />
<div className="flex items-center gap-1">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
{event.replies?.length ? (
<div className="flex flex-col gap-3 pl-4">
<div className="flex flex-col gap-3 pl-6 border-l border-black/10 dark:border-white/10">
{event.replies?.map((childEvent) => (
<NoteChild key={childEvent.id} event={childEvent} />
))}
</div>
</div>
) : null}
</Note.Root>
</Note.Provider>
);
});
export default NoteParent;
@@ -1,36 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import { SubReply } from "./subReply";
import { Note } from "@/components/note";
export function Reply({ event }: { event: EventWithReplies }) {
return (
<Note.Provider event={event}>
<Note.Root className="border-t border-neutral-100 dark:border-neutral-900">
<div className="px-3 h-14 flex items-center justify-between">
<Note.User />
<Note.Menu />
</div>
<Note.ContentLarge className="px-3" />
<div className="mt-3 flex items-center gap-4 px-3 h-14">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
<div
className={cn(
event.replies?.length > 0
? "py-2 pl-3 flex flex-col gap-3 divide-y divide-neutral-100 bg-neutral-50 dark:bg-white/5 border-l-2 border-blue-500 dark:divide-neutral-900"
: "",
)}
>
{event.replies?.length > 0
? event.replies?.map((childEvent) => (
<SubReply key={childEvent.id} event={childEvent} />
))
: null}
</div>
</Note.Root>
</Note.Provider>
);
}
@@ -1,50 +0,0 @@
import type { EventWithReplies } from "@lume/types";
import { Spinner } from "@lume/ui";
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Reply } from "./reply";
import { LumeEvent } from "@lume/system";
export function ReplyList({
eventId,
className,
}: {
eventId: string;
className?: string;
}) {
const [t] = useTranslation();
const [data, setData] = useState<null | EventWithReplies[]>(null);
useEffect(() => {
async function getReplies() {
const events = await LumeEvent.getReplies(eventId);
setData(events);
}
getReplies();
}, [eventId]);
return (
<div className={cn("flex flex-col", className)}>
<div className="h-11 flex px-3 items-center text-sm font-semibold text-neutral-700 dark:text-neutral-300 border-t border-neutral-100 dark:border-neutral-900">
Replies ({data?.length ?? 0})
</div>
{!data ? (
<div className="flex h-16 items-center justify-center p-3">
<Spinner className="size-5" />
</div>
) : data.length === 0 ? (
<div className="flex w-full items-center justify-center">
<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">
{t("note.reply.empty")}
</p>
</div>
</div>
) : (
data.map((event) => <Reply key={event.id} event={event} />)
)}
</div>
);
}
@@ -1,26 +0,0 @@
import type { NostrEvent } from "@lume/types";
import { Note } from "@/components/note";
export function SubReply({
event,
}: {
event: NostrEvent;
rootEventId?: string;
}) {
return (
<Note.Provider event={event}>
<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-3 flex items-center gap-4 px-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</Note.Root>
</Note.Provider>
);
}
+47 -60
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 { NostrQuery } from "@lume/system"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types"; 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")({
@@ -19,7 +21,7 @@ export const Route = createFileRoute("/global")({
}; };
}, },
beforeLoad: async () => { beforeLoad: async () => {
const settings = await NostrQuery.getSettings(); const settings = await NostrQuery.getUserSettings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
@@ -42,55 +44,63 @@ export function Screen() {
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: NostrEvent) => { const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} className="mb-3" />;
default: { default: {
const isConversation = if (event.isConversation) {
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention") return (
.length > 0; <Conversation key={event.id} className="mb-3" event={event} />
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0; );
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
} }
if (event.isQuote) {
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />; return <Quote key={event.id} event={event} className="mb-3" />;
} }
return <TextNote 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
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? ( {isFetching && !isLoading && !isFetchingNextPage ? (
<div className="w-full h-11 flex items-center justify-center"> <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span> <span className="text-sm font-medium">
Getting new notes...
</span>
</div> </div>
</div> </div>
) : null} ) : null}
{isLoading ? ( {isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2"> <div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span> <span className="text-sm font-medium">Loading...</span>
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<Empty /> <div className="flex items-center justify-center">
Yo. You're catching up on all the things happening around you.
</div>
) : ( ) : (
<Virtualizer overscan={3}> data.map((item) => renderItem(item))
{data.map((item) => renderItem(item))}
</Virtualizer>
)} )}
{data?.length && hasNextPage ? ( {data?.length && hasNextPage ? (
<div> <div>
@@ -98,7 +108,7 @@ export function Screen() {
type="button" type="button"
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading} 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" 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 ? ( {isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
@@ -111,38 +121,15 @@ export function Screen() {
</button> </button>
</div> </div>
) : null} ) : null}
</div> </Virtualizer>
); </ScrollArea.Viewport>
} <ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
function Empty() { orientation="vertical"
return (
<div className="flex flex-col py-10 gap-10">
<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">
<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" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
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"
> >
<ArrowRightIcon className="size-5" /> <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]" />
Show trending notes </ScrollArea.Scrollbar>
</Link> <ScrollArea.Corner className="bg-transparent" />
<Link </ScrollArea.Root>
to="/trending/users"
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"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
); );
} }
+51 -68
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 { NostrAccount, NostrQuery } from "@lume/system"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types"; 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")({
@@ -19,9 +21,9 @@ export const Route = createFileRoute("/group")({
}; };
}, },
beforeLoad: async ({ search }) => { beforeLoad: async ({ search }) => {
const key = `lume_group_${search.label}`; const key = `lume:group:${search.label}`;
const groups = (await NostrQuery.getNstore(key)) as string[]; const groups: string[] = await NostrQuery.getNstore(key);
const settings = await NostrQuery.getSettings(); const settings = await NostrQuery.getUserSettings();
if (!groups?.length) { if (!groups?.length) {
throw redirect({ throw redirect({
@@ -33,10 +35,7 @@ export const Route = createFileRoute("/group")({
}); });
} }
return { return { groups, settings };
groups,
settings,
};
}, },
component: Screen, component: Screen,
}); });
@@ -55,60 +54,67 @@ 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 NostrQuery.getLocalEvents(groups, 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: NostrEvent) => { const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} className="mb-3" />;
default: { default: {
const isConversation = if (event.isConversation) {
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention") return (
.length > 0; <Conversation key={event.id} className="mb-3" event={event} />
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0; );
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
} }
if (event.isQuote) {
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />; return <Quote key={event.id} event={event} className="mb-3" />;
} }
return <TextNote 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
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? ( {isFetching && !isLoading && !isFetchingNextPage ? (
<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"> <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span> <span className="text-sm font-medium">
Getting new notes...
</span>
</div> </div>
</div> </div>
) : null} ) : null}
{isLoading ? ( {isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2"> <div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span> <span className="text-sm font-medium">Loading...</span>
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<Empty /> <div className="flex items-center justify-center">
Yo. You're catching up on all the things happening around you.
</div>
) : ( ) : (
<Virtualizer overscan={3}> data.map((item) => renderItem(item))
{data.map((item) => renderItem(item))}
</Virtualizer>
)} )}
{data?.length && hasNextPage ? ( {data?.length && hasNextPage ? (
<div> <div>
@@ -116,7 +122,7 @@ export function Screen() {
type="button" type="button"
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading} 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" 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 ? ( {isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
@@ -129,38 +135,15 @@ export function Screen() {
</button> </button>
</div> </div>
) : null} ) : null}
</div> </Virtualizer>
); </ScrollArea.Viewport>
} <ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
function Empty() { orientation="vertical"
return (
<div className="flex flex-col py-10 gap-10">
<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">
<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" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
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"
> >
<ArrowRightIcon className="size-5" /> <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]" />
Show trending notes </ScrollArea.Scrollbar>
</Link> <ScrollArea.Corner className="bg-transparent" />
<Link </ScrollArea.Root>
to="/trending/users"
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"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
); );
} }
+69 -35
View File
@@ -1,16 +1,21 @@
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 { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { NostrAccount } from "@lume/system";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
beforeLoad: async () => { beforeLoad: async () => {
await checkForAppUpdates(true); // check for app updates // Check for app updates
// TODO: move this function to rust
await checkForAppUpdates(true);
// Get all accounts
// TODO: use emit & listen
const accounts = await NostrAccount.getAccounts(); const accounts = await NostrAccount.getAccounts();
if (accounts.length < 1) { if (accounts.length < 1) {
@@ -29,11 +34,11 @@ function Screen() {
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const context = 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 status = await NostrAccount.loadAccount(npub); const status = await NostrAccount.loadAccount(npub);
@@ -41,15 +46,15 @@ function Screen() {
return navigate({ return navigate({
to: "/$account/home", to: "/$account/home",
params: { account: npub }, params: { account: npub },
search: {
accounts: context.accounts,
},
replace: true, replace: true,
}); });
} }
} catch (e) { } catch (e) {
setLoading(false); setLoading({ npub: "", status: false });
toast.error(String(e)); await message(String(e), {
title: "Account",
kind: "error",
});
} }
}; };
@@ -60,47 +65,76 @@ 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
className="relative flex flex-col items-center justify-between w-full h-full"
>
<div
data-tauri-drag-region
className="absolute top-0 left-0 h-14 w-full"
/>
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
<div className="text-center"> <div className="text-center">
<h2 className="text-xl text-neutral-700 dark:text-neutral-300"> <h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300">
{currentDate} {currentDate}
</h2> </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">
{loading ? (
<div className="inline-flex size-6 items-center justify-center">
<Spinner className="size-6" />
</div> </div>
) : ( <div className="flex flex-col items-center flex-1 w-full gap-3">
<> <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 dark:bg-white/10 dark:ring-1 ring-white/15">
{context.accounts.map((account) => ( {context.accounts.map((account) => (
<button <div
key={account} key={account}
type="button"
onClick={() => select(account)} onClick={() => select(account)}
onKeyDown={() => select(account)}
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
> >
<User.Provider pubkey={account}> <User.Provider pubkey={account}>
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10"> <User.Root className="flex items-center gap-2.5 p-3">
<User.Avatar className="size-20 rounded-full object-cover" /> <User.Avatar className="rounded-full size-10" />
<div className="inline-flex flex-col items-start">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" /> <User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{displayNpub(account, 16)}
</span>
</div>
</User.Root> </User.Root>
</User.Provider> </User.Provider>
</button> <div className="inline-flex items-center justify-center size-10">
))} {loading.npub === account ? (
<Link to="/landing"> loading.status ? (
<div className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10"> <Spinner />
<div className="flex size-20 items-center justify-center rounded-full bg-black/5 dark:bg-white/5"> ) : null
<PlusIcon className="size-8" /> ) : null}
</div> </div>
<p className="font-medium leading-tight">Add</p> </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> </div>
</Link> </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="flex-1" />
</div> </div>
); );
} }
+16 -16
View File
@@ -9,27 +9,27 @@ function Screen() {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="flex flex-col justify-center items-center h-screen w-screen" className="flex flex-col items-center justify-center w-screen h-screen"
> >
<div className="mx-auto max-w-xs lg:max-w-md w-full"> <div className="w-full max-w-xs mx-auto lg:max-w-md">
<div className="flex w-full flex-col gap-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50 px-2"> <div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
<div className="h-20 flex items-center border-b border-neutral-100 dark:border-white/5"> <div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5">
<Link <Link
to="/auth/new/profile" to="/auth/create-profile"
className="h-14 w-full flex items-center justify-center gap-2 hover:bg-neutral-100 dark:hover:bg-white/10 rounded-lg px-2" 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="size-9 shrink-0 rounded-full inline-flex items-center justify-center"> <div className="inline-flex items-center justify-center rounded-full size-9 shrink-0">
<img <img
src="/icon.jpeg" src="/icon.jpeg"
alt="App Icon" alt="App Icon"
className="size-9 object-cover rounded-full" className="object-cover rounded-full size-9"
/> />
</div> </div>
<div className="flex-1 inline-flex flex-col"> <div className="inline-flex flex-col flex-1">
<span className="leading-tight font-semibold"> <span className="font-semibold leading-tight">
Create new account Create new account
</span> </span>
<span className="leading-tight text-sm text-neutral-500"> <span className="text-sm leading-tight text-neutral-500">
Use everywhere Use everywhere
</span> </span>
</div> </div>
@@ -37,19 +37,19 @@ function Screen() {
</div> </div>
<div className="flex flex-col gap-1 pb-2.5"> <div className="flex flex-col gap-1 pb-2.5">
<Link <Link
to="/auth/privkey" to="/auth/import"
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10" 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="size-9 inline-flex items-center justify-center"> <div className="inline-flex items-center justify-center size-9">
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" /> <KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div> </div>
Login with Private Key Login with Private Key
</Link> </Link>
<Link <Link
to="/auth/remote" to="/auth/remote"
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10" 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="size-9 inline-flex items-center justify-center"> <div className="inline-flex items-center justify-center size-9">
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" /> <RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
</div> </div>
Nostr Connect Nostr Connect
+125 -39
View File
@@ -2,15 +2,23 @@ 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 } from "@lume/icons"; import { ArrowRightCircleIcon, ArrowUpIcon } from "@lume/icons";
import { NostrAccount, NostrQuery } from "@lume/system"; import { LumeEvent, NostrAccount, NostrQuery } from "@lume/system";
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types"; import { type ColumnRouteSearch, Kind, type Meta } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { useInfiniteQuery } from "@tanstack/react-query"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { redirect } from "@tanstack/react-router"; import { type InfiniteData, useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useRef, useState } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
type Payload = {
raw: string;
parsed: Meta;
};
export const Route = createFileRoute("/newsfeed")({ export const Route = createFileRoute("/newsfeed")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
@@ -20,10 +28,10 @@ export const Route = createFileRoute("/newsfeed")({
}; };
}, },
beforeLoad: async ({ search }) => { beforeLoad: async ({ search }) => {
const settings = await NostrQuery.getSettings(); const isContactListEmpty = await NostrAccount.isContactListEmpty();
const contacts = await NostrAccount.getContactList(); const settings = await NostrQuery.getUserSettings();
if (!contacts.length) { if (isContactListEmpty) {
throw redirect({ throw redirect({
to: "/create-newsfeed/users", to: "/create-newsfeed/users",
search: { search: {
@@ -33,14 +41,14 @@ export const Route = createFileRoute("/newsfeed")({
}); });
} }
return { settings, contacts }; return { settings };
}, },
component: Screen, component: Screen,
}); });
export function Screen() { export function Screen() {
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch(); const { label, account } = Route.useSearch();
const { contacts, settings } = Route.useRouteContext();
const { const {
data, data,
isLoading, isLoading,
@@ -52,57 +60,68 @@ 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 NostrQuery.getLocalEvents(contacts, 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,
}); });
const renderItem = (event: NostrEvent) => { const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} className="mb-3" />;
default: { default: {
const isConversation = if (event.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) {
return ( return (
<Conversation <Conversation key={event.id} className="mb-3" event={event} />
key={event.id}
className="mb-3"
event={event}
gossip={settings?.gossip}
/>
); );
} }
if (event.isQuote) {
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />; return <Quote key={event.id} event={event} className="mb-3" />;
} }
return <TextNote key={event.id} event={event} className="mb-3" />; return <TextNote key={event.id} event={event} className="mb-3" />;
} }
} }
},
[data],
);
useEffect(() => {
const unlisten = listen("synced", async () => {
await queryClient.invalidateQueries({ queryKey: [label, account] });
});
return () => {
unlisten.then((f) => f());
}; };
}, []);
return ( return (
<div className="p-2 w-full h-full overflow-y-auto scrollbar-none"> <ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
<Listerner />
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? ( {isFetching && !isLoading && !isFetchingNextPage ? (
<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"> <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span> <span className="text-sm font-medium">
Getting new notes...
</span>
</div> </div>
</div> </div>
) : null} ) : null}
{isLoading ? ( {isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2"> <div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span> <span className="text-sm font-medium">Loading...</span>
</div> </div>
@@ -111,9 +130,7 @@ export function Screen() {
Yo. You're catching up on all the things happening around you. Yo. You're catching up on all the things happening around you.
</div> </div>
) : ( ) : (
<Virtualizer overscan={3}> data.map((item) => renderItem(item))
{data.map((item) => renderItem(item))}
</Virtualizer>
)} )}
{data?.length && hasNextPage ? ( {data?.length && hasNextPage ? (
<div> <div>
@@ -121,7 +138,7 @@ export function Screen() {
type="button" type="button"
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading} 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" 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 ? ( {isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
@@ -134,6 +151,75 @@ export function Screen() {
</button> </button>
</div> </div>
) : null} ) : 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>
);
}
function Listerner() {
const { queryClient } = Route.useRouteContext();
const { label, account } = Route.useSearch();
const [events, setEvents] = useState<LumeEvent[]>([]);
const pushNewEvents = async () => {
await queryClient.setQueryData(
[label, account],
(oldData: InfiniteData<LumeEvent[], number> | undefined) => {
const firstPage = oldData?.pages[0];
if (firstPage) {
return {
...oldData,
pages: [
{
...firstPage,
posts: [...events, ...firstPage],
},
...oldData.pages.slice(1),
],
};
}
},
);
await queryClient.invalidateQueries({ queryKey: [label, account] });
};
useEffect(() => {
const unlisten = getCurrentWindow().listen<Payload>("new_event", (data) => {
const event = LumeEvent.from(data.payload.raw, data.payload.parsed);
setEvents((prev) => [event, ...prev]);
});
NostrQuery.listenLocalEvent().then(() => console.log("listen"));
return () => {
unlisten.then((f) => f());
NostrQuery.unlisten().then(() => console.log("unlisten"));
};
}, []);
if (!events?.length) return null;
return (
<div className="z-50 fixed top-0 left-0 w-full h-14 flex items-center justify-center px-3">
<button
type="button"
onClick={() => pushNewEvents()}
className="w-max h-8 pl-2 pr-3 inline-flex items-center justify-center gap-1.5 rounded-full shadow-lg text-sm font-medium text-white bg-black dark:text-black dark:bg-white"
>
<ArrowUpIcon className="size-4" />
{events.length} new notes
</button>
</div> </div>
); );
} }
-61
View File
@@ -1,61 +0,0 @@
import { ZapIcon } from "@lume/icons";
import { NostrAccount } from "@lume/system";
import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createLazyFileRoute("/nwc")({
component: Screen,
});
function Screen() {
const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false);
const save = async () => {
const nwc = await NostrAccount.setWallet(uri);
setIsDone(nwc);
};
return (
<Container withDrag>
<div className="h-full w-full flex-1 px-5">
{!isDone ? (
<>
<div className="flex flex-col gap-2">
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
<ZapIcon className="size-5" />
</div>
<div>
<h3 className="text-2xl font-light">
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
to start zapping to your favorite content and creator.
</h3>
</div>
</div>
<div className="mt-10 flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<label>Paste a Nostr Wallet Connect connection string</label>
<textarea
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-24 w-full rounded-lg border-neutral-300 bg-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="button"
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"
>
Save & Connect
</button>
</div>
</>
) : (
<div>Done</div>
)}
</div>
</Container>
);
}
+12 -4
View File
@@ -22,7 +22,7 @@ function Screen() {
</p> </p>
</div> </div>
<div className="px-3 flex flex-col gap-3"> <div className="px-3 flex flex-col gap-3">
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
01. 01.
</div> </div>
@@ -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"
@@ -43,7 +45,7 @@ function Screen() {
</video> </video>
</div> </div>
</div> </div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
02. 02.
</div> </div>
@@ -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"
@@ -64,7 +68,7 @@ function Screen() {
</video> </video>
</div> </div>
</div> </div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
03. 03.
</div> </div>
@@ -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"
@@ -85,7 +91,7 @@ function Screen() {
</video> </video>
</div> </div>
</div> </div>
<div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10 backdrop-blur-lg"> <div className="relative flex flex-col items-center justify-center rounded-xl bg-black/10 dark:bg-white/10">
<div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400"> <div className="absolute top-2 left-3 text-2xl font-semibold font-serif text-neutral-600 dark:text-neutral-400">
04. 04.
</div> </div>
@@ -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>
);
}
+361
View File
@@ -0,0 +1,361 @@
import { Note } from "@/components/note";
import { User } from "@/components/user";
import { HorizontalDotsIcon, InfoIcon, RepostIcon } from "@lume/icons";
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
import { Kind } from "@lume/types";
import { Spinner } from "@lume/ui";
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 { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { open } from "@tauri-apps/plugin-shell";
import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { Virtualizer } from "virtua";
export const Route = createFileRoute("/panel/$account")({
beforeLoad: async ({ context }) => {
console.log(context);
},
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const { queryClient } = Route.useRouteContext();
const { isLoading, data } = useQuery({
queryKey: ["notification", account],
queryFn: async () => {
console.log(queryClient);
const events = await NostrQuery.getNotifications();
return events;
},
select: (events) => {
const zaps = new Map<string, LumeEvent[]>();
const reactions = new Map<string, LumeEvent[]>();
const texts = events.filter((ev) => ev.kind === Kind.Text);
const zapEvents = events.filter((ev) => ev.kind === Kind.ZapReceipt);
const reactEvents = events.filter(
(ev) => ev.kind === Kind.Repost || ev.kind === Kind.Reaction,
);
for (const event of reactEvents) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (reactions.has(rootId)) {
reactions.get(rootId).push(event);
} else {
reactions.set(rootId, [event]);
}
}
}
for (const event of zapEvents) {
const rootId = event.tags.filter((tag) => tag[0] === "e")[0]?.[1];
if (rootId) {
if (zaps.has(rootId)) {
zaps.get(rootId).push(event);
} else {
zaps.set(rootId, [event]);
}
}
}
return { texts, zaps, reactions };
},
});
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(),
}),
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 invoke("force_quit"),
}),
]);
const menu = await Menu.new({
items: menuItems,
});
await menu.popup().catch((e) => console.error(e));
}, []);
useEffect(() => {
const unlisten = getCurrentWindow().listen("notification", async (data) => {
const event: LumeEvent = JSON.parse(data.payload as string);
await queryClient.setQueryData(
["notification", account],
(data: LumeEvent[]) => [event, ...data],
);
});
return () => {
unlisten.then((f) => f());
};
}, []);
if (isLoading) {
return (
<div className="size-full flex items-center justify-center">
<Spinner />
</div>
);
}
return (
<div className="flex flex-col size-full overflow-hidden">
<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={(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 flex-col h-full">
<Tabs.List className="h-8 shrink-0 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>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="min-h-0 flex-1 overflow-x-hidden"
>
<Tab value="replies">
{data.texts.map((event, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<TextNote key={event.id + index} event={event} />
))}
</Tab>
<Tab value="reactions">
{[...data.reactions.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 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 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">
{[...data.zaps.entries()].map(([root, events]) => (
<div
key={root}
className="flex flex-col gap-1 p-2 mb-2 rounded-lg shrink-0 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 p-[2px]">
<User.Avatar className="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>
<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.Root>
</div>
);
}
function Tab({ value, children }: { value: string; children: ReactNode[] }) {
const ref = useRef<HTMLDivElement>(null);
return (
<Tabs.Content value={value} className="size-full">
<ScrollArea.Viewport ref={ref} className="h-full px-2 pt-2">
<Virtualizer scrollRef={ref}>{children}</Virtualizer>
</ScrollArea.Viewport>
</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" />
</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 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" />
<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">
{[...new Set(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>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text";
import { LumeEvent, NostrQuery } from "@lume/system";
import { Kind, type NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { fetch } from "@tauri-apps/plugin-http";
import { useCallback, useRef } from "react";
import { Virtualizer } from "virtua";
type Search = {
query: string;
};
export const Route = createFileRoute("/search/notes")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { query } = Route.useSearch();
const { isLoading, data } = useQuery({
queryKey: ["search", query],
queryFn: async () => {
try {
const res = await fetch(
`https://api.nostr.wine/search?query=${query}&kind=1&limit=50`,
);
const content = await res.json();
const events = content.data as NostrEvent[];
const lumeEvents = await Promise.all(
events.map(async (item): Promise<LumeEvent> => {
const event = await LumeEvent.build(item);
return event;
}),
);
return lumeEvents.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
});
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(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" />;
}
}
},
[data],
);
return (
<ScrollArea.Viewport ref={ref} className="h-full p-3">
<Virtualizer scrollRef={ref}>
{isLoading ? (
<div className="flex items-center justify-center w-full h-11 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Searching...</span>
</div>
) : (
data.map((item) => renderItem(item))
)}
</Virtualizer>
</ScrollArea.Viewport>
);
}
+31 -131
View File
@@ -1,144 +1,44 @@
import { SearchIcon } from "@lume/icons"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { type NostrEvent, Kind } from "@lume/types"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Spinner } from "@lume/ui";
import { Note } from "@/components/note"; type Search = {
import { User } from "@/components/user"; query: string;
import { createFileRoute } from "@tanstack/react-router"; };
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { LumeWindow } from "@lume/system";
export const Route = createFileRoute("/search")({ export const Route = createFileRoute("/search")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const [loading, setLoading] = useState(false); const { query } = Route.useSearch();
const [events, setEvents] = useState<NostrEvent[]>([]);
const [search, setSearch] = useState("");
const [searchValue] = useDebounce(search, 500);
const searchEvents = async () => {
try {
setLoading(true);
const query = `https://api.nostr.wine/search?query=${searchValue}&kind=0,1`;
const res = await fetch(query);
const content = await res.json();
const events = content.data as NostrEvent[];
const sorted = events.sort((a, b) => b.created_at - a.created_at);
setLoading(false);
setEvents(sorted);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
useEffect(() => {
if (searchValue.length >= 3 && searchValue.length < 500) {
searchEvents();
}
}, [searchValue]);
return ( return (
<div data-tauri-drag-region className="flex flex-col w-full h-full"> <div className="flex flex-col h-full">
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5"> <div
<div data-tauri-drag-region className="w-full h-4 shrink-0" /> data-tauri-drag-region
<input className="shrink-0 flex items-end gap-1 h-20 px-3 pb-3 w-full border-b border-black/10 dark:border-white/10"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") searchEvents();
}}
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"
/>
</div>
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
{loading ? (
<div className="w-full h-full flex items-center justify-center">
<Spinner />
</div>
) : events.length ? (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Users
</div>
<div className="flex-1 flex flex-col gap-1">
{events
.filter((ev) => ev.kind === Kind.Metadata)
.map((event, index) => (
<SearchUser key={event.pubkey + index} event={event} />
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
Notes
</div>
<div className="flex-1 flex flex-col gap-3">
{events
.filter((ev) => ev.kind === Kind.Text)
.map((event) => (
<SearchNote key={event.id} event={event} />
))}
</div>
</div>
</div>
) : null}
{!loading && !events.length ? (
<div className="h-full flex items-center justify-center flex-col gap-3">
<div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center">
<SearchIcon className="size-6" />
</div>
Try searching for people, notes, or keywords
</div>
) : null}
</div>
</div>
);
}
function SearchUser({ event }: { event: NostrEvent }) {
return (
<button
key={event.id}
type="button"
onClick={() => LumeWindow.openProfile(event.pubkey)}
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
> >
<User.Provider pubkey={event.pubkey} embedProfile={event.content}> Search result for: <span className="font-semibold">{query}</span>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-9 rounded-full shrink-0" />
<div className="inline-flex items-center gap-1.5">
<User.Name className="font-semibold" />
<User.NIP05 />
</div> </div>
</User.Root> <ScrollArea.Root
</User.Provider> type={"scroll"}
</button> scrollHideDelay={300}
); className="overflow-hidden size-full flex-1"
} >
<Outlet />
function SearchNote({ event }: { event: NostrEvent }) { <ScrollArea.Scrollbar
return ( className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50"> orientation="vertical"
<Note.Provider event={event}> >
<Note.Root> <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]" />
<div className="px-3 h-14 flex items-center justify-between"> </ScrollArea.Scrollbar>
<Note.User /> <ScrollArea.Corner className="bg-transparent" />
<Note.Menu /> </ScrollArea.Root>
</div>
<Note.Content className="px-3" quote={false} mention={false} />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<Note.Open />
</div>
</Note.Root>
</Note.Provider>
</div> </div>
); );
} }
+101
View File
@@ -0,0 +1,101 @@
import { User } from "@/components/user";
import { LumeWindow, NostrQuery } from "@lume/system";
import type { NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { fetch } from "@tauri-apps/plugin-http";
import { useRef } from "react";
import { Virtualizer } from "virtua";
type Search = {
query: string;
};
type UserItem = {
pubkey: string;
profile: string;
};
export const Route = createFileRoute("/search/users")({
validateSearch: (search: Record<string, string>): Search => {
return {
query: search.query,
};
},
beforeLoad: async () => {
const settings = await NostrQuery.getUserSettings();
return { settings };
},
component: Screen,
});
function Screen() {
const { query } = Route.useSearch();
const { isLoading, data } = useQuery({
queryKey: ["search", query],
queryFn: async () => {
try {
const res = await fetch(
`https://api.nostr.wine/search?query=${query}&kind=0&limit=100`,
);
const content = await res.json();
const events = content.data as NostrEvent[];
const users: UserItem[] = events.map((ev) => ({
pubkey: ev.pubkey,
profile: ev.content,
}));
return users;
} catch (e) {
throw new Error(e);
}
},
refetchOnWindowFocus: false,
});
const ref = useRef<HTMLDivElement>(null);
return (
<ScrollArea.Viewport ref={ref} className="h-full px-3 pt-3">
<Virtualizer scrollRef={ref}>
{isLoading ? (
<div className="flex items-center justify-center w-full h-11 gap-2">
<Spinner className="size-5" />
<span className="text-sm font-medium">Searching...</span>
</div>
) : (
data.map((item) => (
<div
key={item.pubkey}
className="w-full p-3 mb-2 overflow-hidden bg-white rounded-lg h-max dark:bg-black/20 shadow-primary dark:ring-1 ring-neutral-800/50"
>
<User.Provider pubkey={item.pubkey} embedProfile={item.profile}>
<User.Root className="flex flex-col w-full h-full gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User.Avatar className="rounded-full size-7" />
<div className="inline-flex items-center gap-1">
<User.Name className="text-sm leadning-tight max-w-[15rem] truncate font-semibold" />
<User.NIP05 />
</div>
</div>
<button
type="button"
onClick={() => LumeWindow.openProfile(item.pubkey)}
className="inline-flex items-center justify-center w-16 text-sm font-medium rounded-md h-7 bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
View
</button>
</div>
<User.About className="select-text line-clamp-3 max-w-none text-neutral-800 dark:text-neutral-400" />
</User.Root>
</User.Provider>
</div>
))
)}
</Virtualizer>
</ScrollArea.Viewport>
);
}
+8 -19
View File
@@ -8,20 +8,17 @@ import {
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";
import { useTranslation } from "react-i18next";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
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">
@@ -36,9 +33,7 @@ function Screen() {
)} )}
> >
<SettingsIcon className="size-5 shrink-0" /> <SettingsIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">General</p>
{t("settings.general.title")}
</p>
</div> </div>
); );
}} }}
@@ -55,9 +50,7 @@ function Screen() {
)} )}
> >
<UserIcon className="size-5 shrink-0" /> <UserIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">User</p>
{t("settings.user.title")}
</p>
</div> </div>
); );
}} }}
@@ -79,7 +72,7 @@ function Screen() {
); );
}} }}
</Link> </Link>
<Link to="/settings/zap"> <Link to="/settings/wallet">
{({ isActive }) => { {({ isActive }) => {
return ( return (
<div <div
@@ -91,9 +84,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>
); );
}} }}
@@ -110,16 +101,14 @@ function Screen() {
)} )}
> >
<SecureIcon className="size-5 shrink-0" /> <SecureIcon className="size-5 shrink-0" />
<p className="text-sm font-medium"> <p className="text-sm font-medium">Backup</p>
{t("settings.backup.title")}
</p>
</div> </div>
); );
}} }}
</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>
+21 -76
View File
@@ -1,11 +1,11 @@
import { User } from "@/components/user"; import { User } from "@/components/user";
import { NostrAccount } from "@lume/system"; 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 { interface Account {
npub: string; npub: string;
@@ -13,117 +13,62 @@ interface Account {
} }
export const Route = createFileRoute("/settings/backup")({ export const Route = createFileRoute("/settings/backup")({
component: Screen, beforeLoad: async () => {
loader: async () => { const accounts = await NostrAccount.getAccounts();
const npubs = await NostrAccount.getAccounts(); return { accounts };
const accounts: Account[] = [];
for (const npub of npubs) {
const nsec: string = await invoke("get_stored_nsec", { npub });
accounts.push({ npub, 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) => (
<List key={account.npub} account={account} /> <Account key={account} account={account} />
))} ))}
</div> </div>
</div> </div>
); );
} }
function List({ 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="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 w-full flex-col gap-1">
<label
htmlFor="nsec"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Private Key
</label>
<div className="flex items-center gap-2"> <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 <button
type="button" type="button"
onClick={() => copyKey()} 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" 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"
> >
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy Private Key"}
</button> </button>
</div> </div>
</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>
); );
} }
@@ -0,0 +1,39 @@
import { Button, init } from "@getalby/bitcoin-connect-react";
import { NostrAccount } from "@lume/system";
import { createFileRoute } from "@tanstack/react-router";
import { getCurrentWebviewWindow } 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) getCurrentWebviewWindow().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>
);
}
+197 -155
View File
@@ -1,179 +1,102 @@
import { NostrQuery } from "@lume/system"; import { NostrQuery, type Settings } from "@lume/system";
import type { Settings } from "@lume/types";
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 { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { 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";
type Theme = "auto" | "light" | "dark";
export const Route = createFileRoute("/settings/general")({ export const Route = createFileRoute("/settings/general")({
beforeLoad: async () => { beforeLoad: async () => {
const settings = await NostrQuery.getSettings(); const initialSettings = await NostrQuery.getUserSettings();
return { settings }; return { initialSettings };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { 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,
notification: !newSettings.notification,
}));
};
const toggleGossip = async () => { const changeTheme = async (theme: string) => {
setNewSettings((prev) => ({
...prev,
gossip: !newSettings.gossip,
}));
};
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 changeTheme = (theme: string) => {
if (theme === "auto" || theme === "light" || theme === "dark") { if (theme === "auto" || theme === "light" || theme === "dark") {
invoke("plugin:theme|set_theme", { invoke("plugin:theme|set_theme", {
theme: theme, theme: theme,
}).then(() => }).then(() => setTheme(theme));
setNewSettings((prev) => ({
...prev,
theme,
})),
);
} }
}; };
const updateSettings = useDebouncedCallback(() => { const updateSettings = useDebouncedCallback(async () => {
NostrQuery.setSettings(newSettings); const newSettings = JSON.stringify(settings);
await NostrQuery.setUserSettings(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">
<h3 className="font-medium">Notification</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
By turning on push notifications, you'll start getting
notifications from Lume directly.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<Switch.Root
checked={newSettings.notification}
onClick={() => toggleNofitication()}
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"> <div className="flex-1">
<h3 className="font-medium">Relay Hint</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">
Automatically connect to the necessary relay suggested by Use the relay hint if necessary.
Relay Hint when fetching a new event.
</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.gossip} checked={settings.use_relay_hint}
onClick={() => toggleGossip()} 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]" />
@@ -183,40 +106,22 @@ 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 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-semibold">Zap</h3> <h3 className="font-medium">Appearance</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300"> <p className="text-sm text-neutral-700 dark:text-neutral-300">
Show the Zap button in each note and user's profile screen, Require restarting the app to take effect.
use for send bitcoin tip to other users.
</p> </p>
</div> </div>
<div className="w-36 flex justify-end shrink-0"> <div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={newSettings.zap}
onClick={() => toggleZap()}
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 py-3">
<div className="flex-1">
<h3 className="font-semibold">Appearance</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
* Require restarting the app to take effect.
</p>
</div>
<div className="w-36 flex justify-end shrink-0">
<select <select
name="theme" name="theme"
className="bg-transparent shadow-none outline-none rounded-lg border-1 border-black/10 dark:border-white/10 py-1 w-24" className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
defaultValue={settings.theme} defaultValue={theme}
onChange={(e) => changeTheme(e.target.value)} onChange={(e) => changeTheme(e.target.value)}
> >
<option value="auto">Auto</option> <option value="auto">Auto</option>
@@ -225,6 +130,143 @@ function Screen() {
</select> </select>
</div> </div>
</div> </div>
<div className="flex items-start justify-between w-full gap-4 py-3">
<div className="flex-1">
<h3 className="font-medium">Vibrancy Effect</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
Make the window transparent.
</p>
</div>
<div className="flex justify-end w-36 shrink-0">
<Switch.Root
checked={settings.vibrancy}
onClick={() =>
setSettings((prev) => ({
...prev,
vibrancy: !prev.vibrancy,
}))
}
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">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>
</div> </div>
+32 -21
View File
@@ -1,9 +1,9 @@
import { CancelIcon, PlusIcon } from "@lume/icons"; import { CancelIcon, PlusIcon } from "@lume/icons";
import { NostrQuery } from "@lume/system"; 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 () => { loader: async () => {
@@ -15,39 +15,49 @@ 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 { 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 {
setIsLoading(true);
const add = await NostrQuery.connectRelay(data.url); 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>
@@ -55,7 +65,7 @@ function Screen() {
<button <button
type="button" type="button"
onClick={() => NostrQuery.removeRelay(relay)} onClick={() => NostrQuery.removeRelay(relay)}
className="inline-flex items-center justify-center size-7 rounded-md hover:bg-black/10 dark:hover:bg-white/10" 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>
@@ -65,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", {
@@ -75,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>
@@ -88,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>
@@ -111,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>
@@ -120,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>
+21 -21
View File
@@ -5,9 +5,9 @@ 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 () => { beforeLoad: async () => {
@@ -34,31 +34,31 @@ function Screen() {
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,21 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/store/community")({
component: Screen,
});
function Screen() {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-3">
<div className="size-24 bg-blue-100 flex flex-col items-center justify-end overflow-hidden dark:bg-blue-900 rounded-full">
<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" />
</div>
<div className="text-center">
<h1 className="font-semibold text-lg">Coming Soon</h1>
<p className="text-sm text-neutral-700 dark:text-neutral-300 leading-tight">
Enhance your experience <br /> by adding column shared by community.
</p>
</div>
</div>
);
}
@@ -1,69 +0,0 @@
import type { LumeColumn } from "@lume/types";
import { createFileRoute } from "@tanstack/react-router";
import { resolveResource } from "@tauri-apps/api/path";
import { getCurrent } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/store/official")({
beforeLoad: async () => {
const resourcePath = await resolveResource(
"resources/official_columns.json",
);
const officialColumns: LumeColumn[] = JSON.parse(
await readTextFile(resourcePath),
);
return {
officialColumns,
};
},
component: Screen,
});
function Screen() {
const { officialColumns } = Route.useRouteContext();
const install = async (column: LumeColumn) => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "add", column });
};
return (
<div className="flex flex-col gap-3 p-3">
{officialColumns.map((column) => (
<div
key={column.label}
className="relative h-[200px] w-full overflow-hidden rounded-xl bg-gradient-to-tr from-orange-100 to-blue-200 px-3 pt-3"
>
{column.cover ? (
<img
src={column.cover}
srcSet={column.coverRetina}
alt={column.name}
loading="lazy"
decoding="async"
className="absolute left-0 top-0 z-10 h-full w-full object-cover"
/>
) : null}
<div className="absolute bottom-0 left-0 z-20 h-16 w-full bg-black/40 px-3 backdrop-blur-xl">
<div className="flex h-full items-center justify-between">
<div>
<h1 className="font-semibold text-white">{column.name}</h1>
<p className="max-w-[24rem] truncate text-sm text-white/80">
{column.description}
</p>
</div>
<button
type="button"
onClick={() => install(column)}
className="inline-flex h-8 w-16 shrink-0 items-center justify-center rounded-full bg-white/20 text-sm font-medium text-white hover:bg-white hover:text-blue-500"
>
Add
</button>
</div>
</div>
</div>
))}
</div>
);
}
+81 -33
View File
@@ -1,56 +1,104 @@
import { GlobalIcon, LaurelIcon } from "@lume/icons"; import { CommunityIcon, LaurelIcon } from "@lume/icons";
import type { ColumnRouteSearch } from "@lume/types"; import type { LumeColumn } from "@lume/types";
import { cn } from "@lume/utils"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Link } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { resolveResource } from "@tauri-apps/api/path";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { readTextFile } from "@tauri-apps/plugin-fs";
export const Route = createFileRoute("/store")({ export const Route = createFileRoute("/store")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { beforeLoad: async () => {
const path = "resources/official_columns.json";
const resourcePath = await resolveResource(path);
const fileContent = await readTextFile(resourcePath);
const officialColumns: LumeColumn[] = JSON.parse(fileContent);
return { return {
account: search.account, officialColumns,
label: search.label,
name: search.name,
}; };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const { officialColumns } = Route.useRouteContext();
const install = async (column: LumeColumn) => {
const mainWindow = getCurrentWindow();
await mainWindow.emit("columns", { type: "add", column });
};
return ( return (
<div className="flex flex-col h-full"> <div className="size-full">
<div className="px-3 mt-2 mb-1"> <ScrollArea.Root
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5"> type={"scroll"}
<Link to="/store/official" className="flex-1"> scrollHideDelay={300}
{({ isActive }) => ( className="flex-1 overflow-hidden size-full"
<div
className={cn(
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight",
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
> >
<ScrollArea.Viewport className="h-full px-3 ">
<div className="flex flex-col gap-3 mb-10">
<div className="inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="size-7 rounded-md inline-flex items-center justify-center bg-black/10 dark:bg-white/10">
<LaurelIcon className="size-4" /> <LaurelIcon className="size-4" />
</div>
Official Official
</div> </div>
)} <div className="grid grid-cols-3 gap-4">
</Link> {officialColumns.map((column) => (
<Link to="/store/community" className="flex-1">
{({ isActive }) => (
<div <div
className={cn( key={column.label}
"inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-md text-sm font-medium leading-tight", className="relative group flex flex-col w-full aspect-square overflow-hidden bg-white dark:bg-black/20 rounded-xl shadow-primary dark:ring-1 dark:ring-white/5"
isActive ? "bg-neutral-50 dark:bg-white/10" : "opacity-50",
)}
> >
<GlobalIcon className="size-4" /> <div className="hidden group-hover:flex items-center justify-center absolute inset-0 size-full rounded-xl bg-white/20 dark:bg-black/20 backdrop-blur-md">
<button
type="button"
onClick={() => install(column)}
className="w-16 h-8 inline-flex items-center justify-center rounded-full bg-black dark:bg-white text-white dark:text-black text-sm font-semibold"
>
Add
</button>
</div>
<div className="flex-1">
{column.cover ? (
<img
src={column.cover}
srcSet={column.coverRetina}
alt={column.name}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
) : null}
</div>
<div className="shrink-0 h-9 px-3 flex items-center">
<h3 className="text-sm font-semibold truncate w-full">
{column.name}
</h3>
</div>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<div className="inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="size-7 rounded-md inline-flex items-center justify-center bg-black/10 dark:bg-white/10">
<CommunityIcon className="size-4" />
</div>
Community Community
</div> </div>
)} <div className="w-full h-20 rounded-xl flex items-center justify-center text-sm font-medium bg-black/5 dark:bg-white/5">
</Link> Coming Soon.
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto scrollbar-none"> </ScrollArea.Viewport>
<Outlet /> <ScrollArea.Scrollbar
</div> 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>
</div> </div>
); );
} }
+55 -72
View File
@@ -2,19 +2,20 @@ 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 { NostrQuery } from "@lume/system"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { import { type ColumnRouteSearch, Kind } from "@lume/types";
type ColumnRouteSearch,
type NostrEvent,
Kind,
Topic,
} 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";
type Topic = {
content: string[];
};
export const Route = createFileRoute("/topic")({ export const Route = createFileRoute("/topic")({
validateSearch: (search: Record<string, string>): ColumnRouteSearch => { validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
return { return {
@@ -24,9 +25,9 @@ export const Route = createFileRoute("/topic")({
}; };
}, },
beforeLoad: async ({ search }) => { beforeLoad: async ({ search }) => {
const key = `lume_topic_${search.label}`; const key = `lume:topic:${search.label}`;
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[]; const topics: Topic[] = await NostrQuery.getNstore(key);
const settings = await NostrQuery.getSettings(); const settings = await NostrQuery.getUserSettings();
if (!topics?.length) { if (!topics?.length) {
throw redirect({ throw redirect({
@@ -38,16 +39,13 @@ export const Route = createFileRoute("/topic")({
}); });
} }
let hashtags: string[] = []; const hashtags: string[] = [];
for (const topic of topics) { for (const topic of topics) {
hashtags.push(...topic.content); hashtags.push(...topic.content);
} }
return { return { settings, hashtags };
hashtags,
settings,
};
}, },
component: Screen, component: Screen,
}); });
@@ -70,55 +68,63 @@ export function Screen() {
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: NostrEvent) => { const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} className="mb-3" />;
default: { default: {
const isConversation = if (event.isConversation) {
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention") return (
.length > 0; <Conversation key={event.id} className="mb-3" event={event} />
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0; );
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
} }
if (event.isQuote) {
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />; return <Quote key={event.id} event={event} className="mb-3" />;
} }
return <TextNote 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
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<Virtualizer scrollRef={ref}>
{isFetching && !isLoading && !isFetchingNextPage ? ( {isFetching && !isLoading && !isFetchingNextPage ? (
<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"> <div className="flex items-center justify-center w-full mb-3 h-12 bg-black/5 dark:bg-white/5 rounded-xl">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Fetching new notes...</span> <span className="text-sm font-medium">
Getting new notes...
</span>
</div> </div>
</div> </div>
) : null} ) : null}
{isLoading ? ( {isLoading ? (
<div className="flex h-16 w-full items-center justify-center gap-2"> <div className="flex items-center justify-center w-full h-16 gap-2">
<Spinner className="size-5" /> <Spinner className="size-5" />
<span className="text-sm font-medium">Loading...</span> <span className="text-sm font-medium">Loading...</span>
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<Empty /> <div className="flex items-center justify-center">
Yo. You're catching up on all the things happening around you.
</div>
) : ( ) : (
<Virtualizer overscan={3}> data.map((item) => renderItem(item))
{data.map((item) => renderItem(item))}
</Virtualizer>
)} )}
{data?.length && hasNextPage ? ( {data?.length && hasNextPage ? (
<div> <div>
@@ -126,7 +132,7 @@ export function Screen() {
type="button" type="button"
onClick={() => fetchNextPage()} onClick={() => fetchNextPage()}
disabled={isFetchingNextPage || isLoading} 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" 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 ? ( {isFetchingNextPage ? (
<Spinner className="size-5" /> <Spinner className="size-5" />
@@ -139,38 +145,15 @@ export function Screen() {
</button> </button>
</div> </div>
) : null} ) : null}
</div> </Virtualizer>
); </ScrollArea.Viewport>
} <ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
function Empty() { orientation="vertical"
return (
<div className="flex flex-col py-10 gap-10">
<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">
<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" />
</div>
<p className="text-lg font-medium">Your newsfeed is empty</p>
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
Here are few suggestions to get started.
</p>
</div>
<div className="flex flex-col px-3 gap-2">
<Link
to="/trending/notes"
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"
> >
<ArrowRightIcon className="size-5" /> <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]" />
Show trending notes </ScrollArea.Scrollbar>
</Link> <ScrollArea.Corner className="bg-transparent" />
<Link </ScrollArea.Root>
to="/trending/users"
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"
>
<ArrowRightIcon className="size-5" />
Discover trending users
</Link>
</div>
</div>
); );
} }
+52 -12
View File
@@ -1,9 +1,14 @@
import { Conversation } from "@/components/conversation";
import { Quote } from "@/components/quote";
import { RepostNote } from "@/components/repost";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import type { NostrEvent } from "@lume/types"; import { LumeEvent } from "@lume/system";
import { Kind, type NostrEvent } from "@lume/types";
import { Spinner } from "@lume/ui"; import { Spinner } from "@lume/ui";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Await, createFileRoute } from "@tanstack/react-router"; import { Await, createFileRoute } from "@tanstack/react-router";
import { defer } from "@tanstack/react-router"; import { defer } from "@tanstack/react-router";
import { Suspense } from "react"; import { Suspense, useCallback, useRef } from "react";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export const Route = createFileRoute("/trending/notes")({ export const Route = createFileRoute("/trending/notes")({
@@ -15,7 +20,15 @@ export const Route = createFileRoute("/trending/notes")({
signal: abortController.signal, signal: abortController.signal,
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((res) => res.notes.map((item) => item.event) as NostrEvent[]), .then((res) => {
const events: NostrEvent[] = res.notes.map(
(item: { event: NostrEvent }) => item.event,
);
const lumeEvents = Promise.all(
events.map(async (ev) => await LumeEvent.build(ev)),
);
return lumeEvents;
}),
), ),
}; };
} catch (e) { } catch (e) {
@@ -28,12 +41,35 @@ export const Route = createFileRoute("/trending/notes")({
export function Screen() { export function Screen() {
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback((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" />;
}
}
}, []);
return ( return (
<div className="w-full h-full"> <ScrollArea.Root
<Virtualizer overscan={3}> type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="h-full px-3 pb-3">
<Virtualizer scrollRef={ref}>
<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"
@@ -46,14 +82,18 @@ export function Screen() {
} }
> >
<Await promise={data}> <Await promise={data}>
{(notes) => {(notes) => notes.map((event) => renderItem(event))}
notes.map((event) => (
<TextNote key={event.id} event={event} className="mb-3" />
))
}
</Await> </Await>
</Suspense> </Suspense>
</Virtualizer> </Virtualizer>
</div> </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>
); );
} }
+12 -10
View File
@@ -14,23 +14,25 @@ export const Route = createFileRoute("/trending")({
}; };
}, },
beforeLoad: async () => { beforeLoad: async () => {
const settings = await NostrQuery.getSettings(); const settings = await NostrQuery.getUserSettings();
return { settings }; return { settings };
}, },
component: Screen, component: Screen,
}); });
function Screen() { function Screen() {
const search = Route.useSearch();
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="h-11 shrink-0 inline-flex w-full items-center gap-1 px-3"> <div className="shrink-0 h-11 flex items-center w-full gap-1 px-3">
<div className="inline-flex h-full w-full items-center gap-1"> <div className="flex w-full h-full gap-1">
<Link to="/trending/notes"> <Link to="/trending/notes" search={search}>
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium", "inline-flex h-8 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", isActive ? "bg-black/10 dark:bg-white/10" : "opacity-50",
)} )}
> >
<ArticleIcon className="size-4" /> <ArticleIcon className="size-4" />
@@ -38,12 +40,12 @@ function Screen() {
</div> </div>
)} )}
</Link> </Link>
<Link to="/trending/users"> <Link to="/trending/users" search={search}>
{({ isActive }) => ( {({ isActive }) => (
<div <div
className={cn( className={cn(
"inline-flex h-7 w-max items-center justify-center gap-2 rounded-full px-3 text-sm font-medium", "inline-flex h-8 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", isActive ? "bg-black/10 dark:bg-white/10" : "opacity-50",
)} )}
> >
<GroupFeedsIcon className="size-4" /> <GroupFeedsIcon className="size-4" />
@@ -53,7 +55,7 @@ function Screen() {
</Link> </Link>
</div> </div>
</div> </div>
<div className="p-2 flex-1 overflow-y-auto w-full h-full scrollbar-none"> <div className="flex-1 w-full h-full overflow-y-auto scrollbar-none">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
+3 -3
View File
@@ -1,5 +1,5 @@
import { Spinner } from "@lume/ui";
import { User } from "@/components/user"; import { User } from "@/components/user";
import { Spinner } from "@lume/ui";
import { Await, defer } from "@tanstack/react-router"; import { Await, defer } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react"; import { Suspense } from "react";
@@ -45,14 +45,14 @@ export 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-3 p-2 bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl" className="h-max w-full overflow-hidden mb-3 p-2 bg-black/5 dark:bg-white/5 rounded-xl"
> >
<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 h-full w-full flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" /> <User.Avatar className="size-10 rounded-full" />
<User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" /> <User.Name className="leadning-tight max-w-[15rem] truncate font-semibold" />
</div> </div>
<User.Button className="inline-flex h-8 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" /> <User.Button className="inline-flex h-8 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" />
+25 -27
View File
@@ -1,19 +1,19 @@
import { Box, Container, Spinner } from "@lume/ui";
import { User } from "@/components/user";
import { createFileRoute, defer } from "@tanstack/react-router";
import { WindowVirtualizer } from "virtua";
import { Conversation } from "@/components/conversation"; 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 { type NostrEvent, Kind } from "@lume/types"; import { User } from "@/components/user";
import { Suspense } from "react"; import { type LumeEvent, NostrQuery } from "@lume/system";
import { Kind } from "@lume/types";
import { Box, Container, Spinner } from "@lume/ui";
import { createFileRoute, defer } from "@tanstack/react-router";
import { Await } from "@tanstack/react-router"; import { Await } from "@tanstack/react-router";
import { NostrQuery } from "@lume/system"; import { Suspense, useCallback } from "react";
import { WindowVirtualizer } from "virtua";
export const Route = createFileRoute("/users/$pubkey")({ export const Route = createFileRoute("/users/$pubkey")({
beforeLoad: async () => { beforeLoad: async () => {
const settings = await NostrQuery.getSettings(); const settings = await NostrQuery.getUserSettings();
return { settings }; return { settings };
}, },
loader: async ({ params }) => { loader: async ({ params }) => {
@@ -26,45 +26,43 @@ function Screen() {
const { pubkey } = Route.useParams(); const { pubkey } = Route.useParams();
const { data } = Route.useLoaderData(); const { data } = Route.useLoaderData();
const renderItem = (event: NostrEvent) => { const renderItem = useCallback(
(event: LumeEvent) => {
if (!event) return; if (!event) return;
switch (event.kind) { switch (event.kind) {
case Kind.Repost: case Kind.Repost:
return <RepostNote key={event.id} event={event} />; return <RepostNote key={event.id} event={event} className="mb-3" />;
default: { default: {
const isConversation = if (event.isConversation) {
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention") return (
.length > 0; <Conversation key={event.id} className="mb-3" event={event} />
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0; );
if (isConversation) {
return <Conversation key={event.id} event={event} className="mb-3" />;
} }
if (event.isQuote) {
if (isQuote) {
return <Quote key={event.id} event={event} className="mb-3" />; return <Quote key={event.id} event={event} className="mb-3" />;
} }
return <TextNote key={event.id} event={event} className="mb-3" />; return <TextNote key={event.id} event={event} className="mb-3" />;
} }
} }
}; },
[data],
);
return ( return (
<Container withDrag> <Container withDrag>
<Box className="px-0 scrollbar-none bg-black/5 dark:bg-white/5 backdrop-blur-sm"> <Box className="px-0 scrollbar-none bg-black/5 dark:bg-white/5">
<WindowVirtualizer> <WindowVirtualizer>
<User.Provider pubkey={pubkey}> <User.Provider pubkey={pubkey}>
<User.Root> <User.Root>
<User.Cover className="h-44 w-full object-cover" /> <User.Cover className="object-cover w-full h-44" />
<div className="relative -mt-8 flex flex-col px-3"> <div className="relative flex flex-col px-3 -mt-8">
<User.Avatar className="size-14 rounded-full" /> <User.Avatar className="rounded-full size-14" />
<div className="mb-4 inline-flex items-center justify-between"> <div className="inline-flex items-center justify-between mb-4">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User.Name className="text-lg font-semibold leading-tight" /> <User.Name className="text-lg font-semibold leading-tight" />
<User.NIP05 /> <User.NIP05 />
</div> </div>
<User.Button className="h-9 w-24 rounded-full inline-flex items-center justify-center bg-black text-sm font-medium text-white hover:bg-neutral-900 dark:bg-neutral-900" /> <User.Button className="inline-flex items-center justify-center w-24 text-sm font-medium text-white bg-black rounded-full h-9 hover:bg-neutral-900 dark:bg-neutral-900" />
</div> </div>
<User.About /> <User.About />
</div> </div>

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